@vincemakes/kiso-subagent-ext 0.1.45

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # kiso-subagent-ext
2
+
3
+ The kiso official subagent extension: child kiso processes with role
4
+ policies, the kernel untouched.
5
+
6
+ ## How it is loaded
7
+
8
+ Since 0.1.45 this extension ships **built-in** with the kiso CLI — a fresh
9
+ install starts with it registered (the startup banner lists it), with zero
10
+ disk setup. The same artifact can also be installed as a user-level
11
+ extension: copy `dist/kiso-subagent.mjs` into `~/.kiso/extensions/` — the
12
+ user-layer loader accepts exactly this shape.
13
+
14
+ ## Configuration
15
+
16
+ None. Every delegation is asked of the human (no auto-allow); depth is
17
+ guarded so children can never nest.
18
+
19
+ ## Versioning
20
+
21
+ The version counter is this package's own. It is pinned exactly by the kiso
22
+ CLI it ships with; an extension release reaches CLI users through the next
23
+ CLI release.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * kiso (foundation) official subagent extension — ④: child kiso processes with
3
+ * role policies, kernel untouched.
4
+ *
5
+ * `delegate` spawns child kiso processes (the SAME binary) that work in
6
+ * isolated, role-policy-gated environments and report back from their OWN
7
+ * durable session JSONL (children land in the normal sessions directory —
8
+ * durable, auditable, resumable after a parent crash). Depth is guarded
9
+ * (KISO_SUBAGENT_DEPTH ≥ 1 → no delegate) so children can never nest.
10
+ *
11
+ * Approval: no auto-allow — delegate falls in the ask tier, so a human
12
+ * sees every delegation (ruling A: the ask reaches the human directly).
13
+ *
14
+ * Zero runtime dependencies: child_process/fs/os/path are builtins.
15
+ *
16
+ * finding #8: this extension holds NO persistent resources — children are
17
+ * spawned per call and exit on their own, the role-policy temp dirs are
18
+ * cleaned in runChild's finally — so NO dispose is needed, explicitly.
19
+ */
20
+
21
+ import { spawn, execFileSync } from "node:child_process";
22
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { homedir, tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ /** Default per-child timeout (ms) — a subagent must never hang the parent. */
27
+ const TIMEOUT_MS = 10 * 60 * 1000;
28
+ /** Max simultaneous children — the concurrency cap (mapLimited). */
29
+ const CONCURRENCY = 4;
30
+
31
+ const SIX_TOOLS = ["read_file", "list_dir", "search_text", "write_file", "edit_file", "shell"];
32
+ const READ_ONLY = ["read_file", "list_dir", "search_text"];
33
+ const ROLES = ["explorer", "implementer", "reviewer", "tester"];
34
+
35
+ const DELEGATE_PARAMETERS = {
36
+ type: "object",
37
+ properties: {
38
+ tasks: {
39
+ type: "array",
40
+ minItems: 1,
41
+ maxItems: 8,
42
+ items: {
43
+ type: "object",
44
+ properties: {
45
+ role: { type: "string", enum: ROLES },
46
+ task: { type: "string", minLength: 1 },
47
+ },
48
+ required: ["role", "task"],
49
+ },
50
+ },
51
+ },
52
+ required: ["tasks"],
53
+ };
54
+
55
+ export default async function createSubagentExtension() {
56
+ const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
57
+ if (depth >= 1) return { name: "subagent", tools: [] }; // depth guard — no nesting
58
+ return {
59
+ name: "subagent",
60
+ tools: [
61
+ {
62
+ name: "delegate",
63
+ description: "run subagent tasks (explorer/implementer/reviewer/tester) in child kiso processes",
64
+ parameters: DELEGATE_PARAMETERS,
65
+ execute: async (input, ctx) => {
66
+ const tasks = ((input ?? {}).tasks ?? []).slice(0, 8);
67
+ if (tasks.length === 0) return { content: "delegate: no tasks", isError: true };
68
+ const sessionsDir = join(process.env.KISO_HOME ?? join(homedir(), ".kiso"), "sessions");
69
+ // P3: the loop now threads the session id through
70
+ // ToolContext.sessionId — the discovery heuristic below is
71
+ // kept ONLY as a fallback for direct tool use / tests.
72
+ const parentId = ctx.sessionId ?? discoverParentId(sessionsDir);
73
+ const bin = process.env.KISO_SUBAGENT_BIN ?? process.argv[1];
74
+ const timeout = Number.parseInt(process.env.KISO_SUBAGENT_TIMEOUT_MS ?? "", 10) || TIMEOUT_MS;
75
+ const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
76
+ runChild({
77
+ childId: `sub-${parentId}-${i + 1}-${task.role}`,
78
+ role: task.role,
79
+ task: task.task,
80
+ sessionsDir,
81
+ bin,
82
+ timeout,
83
+ signal: ctx.signal,
84
+ parentCwd: process.cwd(),
85
+ }),
86
+ );
87
+ // Partial success is not overall failure — only ALL failed
88
+ // makes the whole result an error.
89
+ // W12: the blob opens with a machine-readable summary line —
90
+ // the ONE-LINE shape the TUI's settled row renders (└ N
91
+ // tool calls · R roles · F failed · /last for the report).
92
+ // The per-section text below is unchanged — the model's
93
+ // view is preserved, the summary is additive.
94
+ const toolCalls = sections.reduce((n, s) => n + (s.toolCalls ?? 0), 0);
95
+ const roles = new Set(tasks.map((t) => t.role)).size;
96
+ const failed = sections.filter((s) => s.failed).length;
97
+ const summary = `summary: ${toolCalls} tool calls · ${roles} role${roles === 1 ? "" : "s"} · ${failed} failed`;
98
+ return { content: `${summary}\n${sections.map((s) => s.text).join("\n")}`, isError: sections.every((s) => s.failed) };
99
+ },
100
+ },
101
+ ],
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The parent session id for the child naming: the explicit
107
+ * KISO_SESSION_ID wins; otherwise the NEWEST *.jsonl in the sessions dir
108
+ * IS the parent (its approval events were just persisted before this tool
109
+ * ran); a constant fallback covers direct tool use / tests.
110
+ */
111
+ function discoverParentId(sessionsDir) {
112
+ if (process.env.KISO_SESSION_ID !== undefined) return process.env.KISO_SESSION_ID;
113
+ let newest = null;
114
+ let newestMtime = -1;
115
+ try {
116
+ for (const file of readdirSync(sessionsDir)) {
117
+ if (!file.endsWith(".jsonl")) continue;
118
+ const st = statSync(join(sessionsDir, file));
119
+ if (st.mtimeMs > newestMtime) {
120
+ newestMtime = st.mtimeMs;
121
+ newest = file.slice(0, -".jsonl".length);
122
+ }
123
+ }
124
+ } catch {
125
+ // no sessions dir yet
126
+ }
127
+ return newest ?? "parent";
128
+ }
129
+
130
+ /** mapLimited — at most `limit` tasks in flight at once. */
131
+ function runLimited(items, limit, fn) {
132
+ const results = new Array(items.length);
133
+ let next = 0;
134
+ async function worker() {
135
+ while (true) {
136
+ const i = next;
137
+ next += 1;
138
+ if (i >= items.length) return;
139
+ results[i] = await fn(items[i], i);
140
+ }
141
+ }
142
+ return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results);
143
+ }
144
+
145
+ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal, parentCwd }) {
146
+ // implementer isolation: a detached git worktree; the child works inside
147
+ // it and its diff comes back. Non-git parents fail the task HONESTLY.
148
+ let worktree = null;
149
+ let childCwd = parentCwd;
150
+ if (role === "implementer") {
151
+ worktree = mkdtempSync(join(tmpdir(), "kiso-subagent-wt-"));
152
+ try {
153
+ execFileSync("git", ["-C", parentCwd, "worktree", "add", "--detach", worktree], { stdio: "ignore" });
154
+ childCwd = worktree;
155
+ } catch (err) {
156
+ rmSync(worktree, { recursive: true, force: true });
157
+ return failSection(childId, role, task, `implementer needs a git repository: ${msg(err)}`);
158
+ }
159
+ }
160
+ // The child-only role policy: one .mjs in its own temp extensions dir.
161
+ const policyDir = mkdtempSync(join(tmpdir(), "kiso-subagent-policy-"));
162
+ writeFileSync(join(policyDir, "policy.mjs"), rolePolicyContent(role), "utf8");
163
+ let keepWorktree = false;
164
+ try {
165
+ const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, task, timeout, signal);
166
+ const extraction = await extractChildResult(sessionsDir, childId, `exit ${code}\n${stdout}`);
167
+ const failed = code !== 0 || killed !== null || extraction.failed;
168
+ let text = `[subagent] ${role}: ${task}\n outcome: ${extraction.outcome}\n tools: ${extraction.toolCalls}`;
169
+ if (killed === "timeout") {
170
+ text += `\n FAILED: timed out after ${timeout}ms (the child process group was killed)`;
171
+ } else if (killed === "abort") {
172
+ text += "\n FAILED: aborted by the parent run (the child process group was killed)";
173
+ } else if (code !== 0) {
174
+ text += `\n FAILED: the child exited with code ${code}\n${stdout}`;
175
+ } else if (extraction.failed) {
176
+ text += `\n FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`;
177
+ }
178
+ if (extraction.text !== "") text += `\n${extraction.text}`;
179
+ if (role === "implementer") {
180
+ const diff = worktreeDiff(worktree);
181
+ if (diff !== null) {
182
+ keepWorktree = true;
183
+ text += `\n diff:\n${diff}\n worktree kept at: ${worktree}`;
184
+ }
185
+ }
186
+ return { failed, text, toolCalls: extraction.toolCalls };
187
+ } finally {
188
+ rmSync(policyDir, { recursive: true, force: true });
189
+ if (worktree !== null && !keepWorktree) rmSync(worktree, { recursive: true, force: true });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * The child process: same binary, detached (own process group — a timeout
195
+ * or abort SIGKILLs the WHOLE group), input piped as the task line + exit
196
+ * (the same shape the CLI e2e drivers use), stdout captured for
197
+ * diagnostics only — the RESULT comes from the child's session JSONL.
198
+ *
199
+ * ENV — deliberately the parent's full environment PLUS the depth guard
200
+ * and the role policy dir. Note the difference from the shell tool (#7):
201
+ * shell = arbitrary commands, stripped by default; delegate = a CONTROLLED
202
+ * spawn the human just approved in the ask tier, so the provider
203
+ * credentials the parent was trusted with ride along.
204
+ */
205
+ function runProcess(childId, bin, cwd, policyDir, task, timeout, signal) {
206
+ const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
207
+ const child = spawn(process.execPath, [bin, "chat", childId], {
208
+ cwd,
209
+ env: {
210
+ ...process.env,
211
+ KISO_SUBAGENT_DEPTH: String(depth + 1),
212
+ KISO_EXTENSIONS_DIR: policyDir,
213
+ // Modes: a headless child has no human — the mode tiers'
214
+ // ask would stall it. Bypass is the neutral tier here; the
215
+ // role policy dir (allow/deny only — a child must never
216
+ // see an ask) stays the child's ONLY gate, exactly as
217
+ // before the mode tiers existed (deny>ask>allow honors its
218
+ // denials; the mode's all-allow never overrides them).
219
+ KISO_MODE: "bypass",
220
+ },
221
+ detached: true,
222
+ stdio: ["pipe", "pipe", "inherit"],
223
+ });
224
+ child.stdin.write(`${task}\nexit\n`);
225
+ child.stdin.end();
226
+ let stdout = "";
227
+ child.stdout.on("data", (d) => {
228
+ stdout += String(d);
229
+ });
230
+ const exited = new Promise((resolve) => {
231
+ child.on("exit", (code, sig) => resolve({ code: code ?? -1, signal: sig }));
232
+ });
233
+ let killed = null;
234
+ const killGroup = () => {
235
+ try {
236
+ process.kill(-child.pid, "SIGKILL");
237
+ } catch {
238
+ // already gone
239
+ }
240
+ };
241
+ const timer = setTimeout(() => {
242
+ killed = "timeout";
243
+ killGroup();
244
+ }, timeout);
245
+ const onAbort = () => {
246
+ killed = "abort";
247
+ killGroup();
248
+ };
249
+ if (signal?.aborted) onAbort();
250
+ else signal?.addEventListener("abort", onAbort, { once: true });
251
+ // The timeout and abort listener live until the child EXITS — clearing
252
+ // them in a finally around the setup would disarm them before the exit.
253
+ return exited.then(({ code }) => {
254
+ clearTimeout(timer);
255
+ signal?.removeEventListener("abort", onAbort);
256
+ return { code, stdout, killed };
257
+ });
258
+ }
259
+
260
+ /** The role policy: read-only for explorer/reviewer, the full six for
261
+ * implementer/tester. Only allow/deny — NEVER ask (a headless child cannot
262
+ * answer an approval prompt; ask would deadlock). */
263
+ export function rolePolicyContent(role) {
264
+ const allowed = role === "implementer" || role === "tester" ? SIX_TOOLS : READ_ONLY;
265
+ return `export default { name: "subagent-${role}", approvals: [{
266
+ decide(call) {
267
+ if (${JSON.stringify(allowed)}.includes(call.name)) return { action: "allow" };
268
+ return { action: "deny", reason: "not allowed for the ${role} role" };
269
+ }
270
+ }] };
271
+ `;
272
+ }
273
+
274
+ /**
275
+ * The RESULT source (hard clause): the child's own session JSONL — its
276
+ * terminal outcome, final assistant text (a projection-equivalent parse:
277
+ * the text_delta events since the last message boundary), and its tool
278
+ * call count. stdout is NEVER a result source — it rides along only as a
279
+ * diagnostic on a non-zero exit or a missing JSONL.
280
+ */
281
+ export async function extractChildResult(sessionsDir, childId, diag) {
282
+ const file = join(sessionsDir, `${childId}.jsonl`);
283
+ // The child's exit event can land a beat before its final JSONL write
284
+ // (the terminal line) is visible — retry briefly before giving up.
285
+ let events = null;
286
+ let lastErr = null;
287
+ for (let attempt = 0; attempt < 10 && (events === null || !events.some((e) => e.type === "terminal")); attempt++) {
288
+ try {
289
+ // The store's JSONL records are {runId, ts, event} wrappers —
290
+ // unwrap; bare events (fixtures) pass through.
291
+ events = readFileSync(file, "utf8")
292
+ .trim()
293
+ .split("\n")
294
+ .filter((l) => l !== "")
295
+ .map((l) => JSON.parse(l))
296
+ .map((r) => r.event ?? r);
297
+ } catch (err) {
298
+ lastErr = err;
299
+ events = null;
300
+ }
301
+ if (events === null || !events.some((e) => e.type === "terminal")) await new Promise((r) => setTimeout(r, 200));
302
+ }
303
+ if (events === null) {
304
+ return { outcome: "missing", toolCalls: 0, text: "", failed: true, reason: `child session JSONL missing: ${msg(lastErr)}`, diag };
305
+ }
306
+ const terminal = events.find((e) => e.type === "terminal");
307
+ if (terminal === undefined) {
308
+ return { outcome: "no-terminal", toolCalls: countToolCalls(events), text: finalText(events), failed: true, reason: "child session has no terminal", diag };
309
+ }
310
+ const outcome = terminal.outcome?.kind ?? "unknown";
311
+ const toolCalls = countToolCalls(events);
312
+ const text = finalText(events);
313
+ return {
314
+ outcome,
315
+ toolCalls,
316
+ text,
317
+ failed: outcome !== "completed",
318
+ reason: outcome === "completed" ? "" : `child ended with ${outcome}`,
319
+ diag: outcome === "completed" ? "" : diag,
320
+ };
321
+ }
322
+
323
+ function countToolCalls(events) {
324
+ return events.filter((e) => e.type === "tool_call_end").length;
325
+ }
326
+
327
+ /** Projection-equivalent: the assistant text since the last flush boundary. */
328
+ function finalText(events) {
329
+ let text = "";
330
+ for (const e of events) {
331
+ if (e.type === "text_delta") text += e.text;
332
+ else if (e.type === "tool_result" || e.type === "user_input") text = "";
333
+ }
334
+ return text;
335
+ }
336
+
337
+ /** The implementer's changes: git diff (with its --stat header) over the
338
+ * worktree — intent-to-add first so NEW files are part of the diff, not
339
+ * silently invisible to `git diff`. null = no changes. */
340
+ function worktreeDiff(worktree) {
341
+ try {
342
+ execFileSync("git", ["-C", worktree, "add", "-N", "."], { stdio: "ignore" });
343
+ const stat = execFileSync("git", ["-C", worktree, "diff", "--stat"], { encoding: "utf8" }).trim();
344
+ const diff = execFileSync("git", ["-C", worktree, "diff"], { encoding: "utf8" });
345
+ if (stat === "" && diff.trim() === "") return null;
346
+ return `${stat}\n${diff}`;
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ function failSection(childId, role, task, reason) {
353
+ return { failed: true, text: `[subagent] ${role}: ${task}\n FAILED: ${reason}`, toolCalls: 0 };
354
+ }
355
+
356
+ const msg = (err) => (err instanceof Error ? err.message : String(err));
package/index.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The published type surface of @vincemakes/kiso-subagent-ext: the
3
+ * default export is the FACTORY (the same contract the user-layer disk
4
+ * loader accepts — a KisoExtension or a factory returning one). The type
5
+ * import from kiso-core is compile-time only — the shipped bundle is
6
+ * self-contained, zero runtime dependencies.
7
+ */
8
+ import type { KisoExtension } from "@vincemakes/kiso-core";
9
+
10
+ declare const createSubagentExtension: () => KisoExtension | Promise<KisoExtension>;
11
+ export default createSubagentExtension;
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@vincemakes/kiso-subagent-ext",
3
+ "version": "0.1.45",
4
+ "description": "kiso official subagent extension \u2014 child kiso processes with role policies, kernel untouched",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/kiso-subagent.mjs",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./dist/kiso-subagent.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "index.d.ts",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "node build.mjs",
22
+ "typecheck": "tsc -p tsconfig.json",
23
+ "test": "vitest run"
24
+ },
25
+ "devDependencies": {
26
+ "@vincemakes/kiso-core": "0.1.35",
27
+ "@types/node": "^26.1.2",
28
+ "typescript": "^5.7.2",
29
+ "vitest": "^3.0.0"
30
+ }
31
+ }