@vincemakes/kiso-subagent-ext 0.45.3 → 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 +22 -2
- package/dist/kiso-subagent.mjs +260 -64
- package/index.d.ts +24 -1
- package/package.json +2 -2
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);
|
|
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
|
|
package/dist/kiso-subagent.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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/
|
|
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);
|
|
217
|
-
// `after` names one
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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 === "
|
|
364
|
+
if (role === "verifier" && after !== undefined) {
|
|
225
365
|
const prior = readResultFile(manifestDir, after);
|
|
226
|
-
worktree = prior.worktree;
|
|
227
366
|
baseRev = prior.baseRev;
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
271
|
-
//
|
|
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 !== "
|
|
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 !== "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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 === "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"
|