@vincemakes/kiso-subagent-ext 0.45.2 → 0.46.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 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 {
@@ -267,8 +419,8 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
267
419
  lines.push(` FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`);
268
420
  }
269
421
  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)
422
+ // the collection (implementers only — a verifier's worktree is a
423
+ // throwaway copy; its changes are not its result)
272
424
  let changedFiles = null;
273
425
  let patchPath = null;
274
426
  let patchBytes = null;
@@ -416,11 +568,12 @@ function artifactDir(home, sessionsDir) {
416
568
  /** DT-1a: every task's inputs are judged BEFORE any child runs; a string is the refusal. */
417
569
  export function validateTask(task, cfg, parentCwd, manifestDir) {
418
570
  if (task === null || typeof task !== "object") return "a task must be an object";
571
+ if (task.role === "tester") return 'unknown role "tester" — it is called "verifier" since 0.46.0';
419
572
  if (!ROLES.includes(task.role)) return `unknown role ${JSON.stringify(task.role)}`;
420
573
  if (typeof task.task !== "string" || task.task.trim() === "") return "task must be a non-empty string";
421
574
  if (task.scope !== undefined) {
422
575
  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";
576
+ if (task.role !== "implementer" && task.role !== "verifier") return "scope applies to implementer and verifier tasks only";
424
577
  }
425
578
  if (task.acceptance !== undefined) {
426
579
  const a = task.acceptance;
@@ -449,7 +602,7 @@ export function validateTask(task, cfg, parentCwd, manifestDir) {
449
602
  }
450
603
  if (task.model !== undefined && !cfg.profiles.includes(task.model)) return `refused: unknown model profile ${JSON.stringify(task.model)} (configured: ${cfg.profiles.join(", ") || "none"})`;
451
604
  if (task.after !== undefined) {
452
- if (task.role !== "tester") return "refused: `after` is for tester tasks";
605
+ if (task.role !== "verifier") return "refused: `after` is for verifier tasks";
453
606
  let prior;
454
607
  try {
455
608
  prior = readResultFile(manifestDir, task.after);
@@ -555,9 +708,51 @@ export function parseChangedFiles(numstat, nameStatus) {
555
708
  const ACCEPTANCE_OUTPUT_CAP = 64 * 1024;
556
709
  const ACCEPTANCE_TAIL = 2 * 1024;
557
710
 
711
+ /** A child in its own process group, so a timeout or an abort kills it
712
+ * whole; win32 has no groups — the child is hidden, and its tree dies by
713
+ * taskkill /T (which walks it by parent). */
714
+ const ownGroup = () => (process.platform === "win32" ? { windowsHide: true } : { detached: true });
715
+
716
+ function killGroup(child) {
717
+ try {
718
+ if (process.platform === "win32") execFileSync("taskkill", ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true });
719
+ else process.kill(-child.pid, "SIGKILL");
720
+ } catch {
721
+ // already gone
722
+ }
723
+ }
724
+
725
+ /** The bash a check runs through on win32 — this build's own copy of the
726
+ * process module's rule (tools-node `process.ts`; zero dependencies here),
727
+ * pinned to it by subagent-win32.test.ts. */
728
+ function resolveBash(env) {
729
+ const fail = (message) => {
730
+ throw Object.assign(new Error(message), { code: "KISO_NO_BASH" });
731
+ };
732
+ const override = env.KISO_BASH;
733
+ if (override !== undefined && override !== "") {
734
+ if (!/^bash(\.exe)?$/i.test(win32.basename(override))) {
735
+ fail(`KISO_BASH must name a bash executable (bash.exe), not ${win32.basename(override)}: kiso's shell commands are POSIX shell`);
736
+ }
737
+ if (!existsSync(override)) fail(`KISO_BASH is set to ${override}, which does not exist`);
738
+ return override;
739
+ }
740
+ for (const root of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) {
741
+ const candidate = win32.join(root, "Git", "bin", "bash.exe");
742
+ if (existsSync(candidate)) return candidate;
743
+ }
744
+ const system32 = win32.join(env.SystemRoot ?? "C:\\Windows", "System32").toLowerCase();
745
+ for (const dir of (env.PATH ?? "").split(win32.delimiter)) {
746
+ if (dir === "" || win32.join(dir, ".").toLowerCase() === system32) continue;
747
+ const candidate = win32.join(dir, "bash.exe");
748
+ if (existsSync(candidate)) return candidate;
749
+ }
750
+ 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");
751
+ }
752
+
558
753
  /** 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
754
+ * through /bin/sh (bash on win32; user-authored), or the evaluator binary with the
755
+ * worktree as its argument. Own process group (the abort and the timeout kill it whole), the
561
756
  * output capped, the tail kept. The exit code proves the command RAN on the tree
562
757
  * as the child left it (patchSha256 names that state); only an evaluator proves
563
758
  * correctness — the child can edit a check's tests. */
@@ -573,7 +768,16 @@ export async function runAcceptance(acceptance, cfg, worktree, baseRev, timeout,
573
768
  } catch {
574
769
  // a non-git worktree: the state hash stays the empty one
575
770
  }
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"] });
771
+ let child;
772
+ try {
773
+ const options = { cwd: worktree, ...ownGroup(), stdio: ["ignore", "pipe", "pipe"] };
774
+ child = kind === "check" ? spawn(process.platform === "win32" ? resolveBash(process.env) : "/bin/sh", ["-c", command], options) : spawn(command, [worktree], options);
775
+ } catch (err) {
776
+ // win32 with no usable bash: the check fails with a message the model can act on
777
+ if (err?.code !== "KISO_NO_BASH") throw err;
778
+ const name = kind === "check" ? { name: acceptance.check, command } : { evaluator: command };
779
+ return { kind, ...name, exitCode: null, passed: false, tail: err.message, durationMs: Date.now() - started, patchSha256, baseRev };
780
+ }
577
781
  let output = "";
578
782
  const capture = (d) => {
579
783
  if (output.length < ACCEPTANCE_OUTPUT_CAP) output += String(d).slice(0, ACCEPTANCE_OUTPUT_CAP - output.length);
@@ -581,20 +785,13 @@ export async function runAcceptance(acceptance, cfg, worktree, baseRev, timeout,
581
785
  child.stdout.on("data", capture);
582
786
  child.stderr.on("data", capture);
583
787
  let killed = null;
584
- const killGroup = () => {
585
- try {
586
- process.kill(-child.pid, "SIGKILL");
587
- } catch {
588
- // already gone
589
- }
590
- };
591
788
  const timer = setTimeout(() => {
592
789
  killed = "timeout";
593
- killGroup();
790
+ killGroup(child);
594
791
  }, timeout);
595
792
  const onAbort = () => {
596
793
  killed = "abort";
597
- killGroup();
794
+ killGroup(child);
598
795
  };
599
796
  if (signal?.aborted) onAbort();
600
797
  else signal?.addEventListener("abort", onAbort, { once: true });
@@ -636,26 +833,32 @@ export function childArgs(bin, childId, taskPath, model) {
636
833
  return [bin, ...(model !== undefined ? ["--model", model] : []), "chat", childId, "--task-file", taskPath];
637
834
  }
638
835
 
639
- function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model, sessionsDir) {
836
+ /** A child's environment — the parent's, plus the depth guard, its role
837
+ * policy and the sessions folder (the foreground and background share it). */
838
+ function childEnv(policyDir, sessionsDir) {
640
839
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
840
+ return {
841
+ ...process.env,
842
+ KISO_SUBAGENT_DEPTH: String(depth + 1),
843
+ KISO_EXTENSIONS_DIR: policyDir,
844
+ // 0.40.0: the child writes its log beside its parent's — the
845
+ // parent reads it back from there — whatever cwd it runs in
846
+ KISO_SESSIONS_DIR: sessionsDir,
847
+ // Modes: a headless child has no human — the mode tiers'
848
+ // ask would stall it. Bypass is the neutral tier here; the
849
+ // role policy dir (allow/deny only — a child must never
850
+ // see an ask) stays the child's ONLY gate, exactly as
851
+ // before the mode tiers existed (deny>ask>allow honors its
852
+ // denials; the mode's all-allow never overrides them).
853
+ KISO_MODE: "bypass",
854
+ };
855
+ }
856
+
857
+ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model, sessionsDir) {
641
858
  const child = spawn(process.execPath, childArgs(bin, childId, taskPath, model), {
642
859
  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,
860
+ env: childEnv(policyDir, sessionsDir),
861
+ ...ownGroup(),
659
862
  stdio: ["pipe", "pipe", "inherit"],
660
863
  });
661
864
  child.stdin.end(); // CX-1 F5: nothing rides stdin — the task is the file
@@ -667,20 +870,13 @@ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, mod
667
870
  child.on("exit", (code, sig) => resolve({ code: code ?? -1, signal: sig }));
668
871
  });
669
872
  let killed = null;
670
- const killGroup = () => {
671
- try {
672
- process.kill(-child.pid, "SIGKILL");
673
- } catch {
674
- // already gone
675
- }
676
- };
677
873
  const timer = setTimeout(() => {
678
874
  killed = "timeout";
679
- killGroup();
875
+ killGroup(child);
680
876
  }, timeout);
681
877
  const onAbort = () => {
682
878
  killed = "abort";
683
- killGroup();
879
+ killGroup(child);
684
880
  };
685
881
  if (signal?.aborted) onAbort();
686
882
  else signal?.addEventListener("abort", onAbort, { once: true });
@@ -694,10 +890,10 @@ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, mod
694
890
  }
695
891
 
696
892
  /** The role policy: read-only for explorer/reviewer, the full six for
697
- * implementer/tester. Only allow/deny — NEVER ask (a headless child cannot
893
+ * implementer/verifier. Only allow/deny — NEVER ask (a headless child cannot
698
894
  * answer an approval prompt; ask would deadlock). */
699
895
  export function rolePolicyContent(role, scope) {
700
- const allowed = role === "implementer" || role === "tester" ? SIX_TOOLS : READ_ONLY;
896
+ const allowed = role === "implementer" || role === "verifier" ? SIX_TOOLS : READ_ONLY;
701
897
  if (scope === undefined) {
702
898
  return `export default { name: "subagent-${role}", approvals: [{
703
899
  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.2",
3
+ "version": "0.46.0",
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.2",
26
+ "@vincemakes/kiso-core": "0.46.0",
27
27
  "@types/node": "^26.1.2",
28
28
  "typescript": "^5.7.2",
29
29
  "vitest": "^3.0.0"