@vincemakes/kiso-subagent-ext 0.47.3 → 0.49.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.
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { spawn, execFileSync } from "node:child_process";
31
31
  import { createHash, randomBytes } from "node:crypto";
32
- import { closeSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
32
+ import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
33
33
  import { homedir, tmpdir } from "node:os";
34
34
  import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
@@ -43,14 +43,30 @@ const BACKGROUND_MAX = 20;
43
43
  /** ADR-0058 3d (D6): a background child's model requests, by default — about
44
44
  * twice the longest real explorer measured (17); 3f tunes it. */
45
45
  const BACKGROUND_MAX_TURNS = 32;
46
+ /** 0.49.0 B: a writer's model requests, by default. The registered rule
47
+ * (ceil(1.25 x p95 of the probe's completed writers)) gave 12 on a
48
+ * one-function probe; the only real writers on record (41 and 66 requests)
49
+ * were both cut by the wall clock unfinished, so 12 would cut healthy work.
50
+ * 128 is the kit's own fallback, above both; re-measured on a representative
51
+ * writer task in 0.50 (the owner's ruling, 2026-10-10). */
52
+ const WRITER_MAX_TURNS = 128;
46
53
  /** ADR-0058 3d (D1): the roles whose unattended contract is defined. */
47
- const BACKGROUND_ROLES = ["explorer", "reviewer"];
48
54
 
49
55
  const SIX_TOOLS = ["read_file", "list_dir", "search_text", "write_file", "edit_file", "shell"];
50
56
  const READ_ONLY = ["read_file", "list_dir", "search_text"];
51
57
  // verifier: runs checks and reports evidence; its changes are never
52
58
  // collected (ADR-0032 Amendment 1 — it was called tester until 0.46.0)
53
59
  const ROLES = ["explorer", "implementer", "reviewer", "verifier"];
60
+ // 0.49.0 C2: a reader's turn budget, whether its parent waits or not. A
61
+ // writer has none until B measures one; it keeps the wall clock.
62
+ const READER_ROLES = ["explorer", "reviewer"];
63
+ // 0.49.0 A: how long a foreground reader delegation waits for its group —
64
+ // the shell's foreground wait (ADR-0058 §3, Amendment 10); the host's
65
+ // `joinMs` overrides it
66
+ const JOIN_MS = 60_000;
67
+ // 0.49.0 C3: what of a foreground child's printed output stays in memory —
68
+ // the rest is in its output.log, never in a string that grows with it.
69
+ const OUTPUT_TAIL_KEEP = 8 * 1024;
54
70
 
55
71
  // DT1a-F2 (owner dogfood 2026-09-08): the model's first delegate call was
56
72
  // refused twice — `scope` on an explorer, then an `acceptance` naming no
@@ -59,79 +75,117 @@ const ROLES = ["explorer", "implementer", "reviewer", "verifier"];
59
75
  // check and profile names are written into the schema when the extension
60
76
  // loads (the tool table is snapshotted per request — F7b — so this is the
61
77
  // table the model reads).
62
- const delegateParameters = (cfg, background) => ({
63
- type: "object",
64
- properties: {
65
- tasks: {
66
- type: "array",
67
- minItems: 1,
68
- maxItems: 8,
69
- items: {
70
- type: "object",
71
- properties: {
72
- role: { type: "string", enum: ROLES },
73
- task: { type: "string", minLength: 1 },
74
- // DT-1a: the contract's inputs. scope = allowed WRITE paths (globs,
75
- // relative to the worktree) — a scoped task has NO shell tool;
76
- // acceptance names a configured check ({ check }) or a parent-held
77
- // evaluator ({ evaluator: absolute path outside the project }) —
78
- // never a command; model names a configured profile; after names
79
- // a completed implementer's childId (verifier only): the verifier
80
- // runs in a copy of that worktree.
81
- scope: {
82
- type: "array",
83
- items: { type: "string", minLength: 1 },
84
- maxItems: 32,
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",
86
- },
87
- acceptance: {
88
- type: "object",
89
- properties: { check: { type: "string", minLength: 1 }, evaluator: { type: "string", minLength: 1 } },
90
- additionalProperties: false,
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`,
78
+ // Plan B (the prefix diet): every field the model can use is listed, and
79
+ // none it cannot — `acceptance` only when the user configured a check or an
80
+ // evaluator, `model` only when a profile exists (an unconfigured field was a
81
+ // paragraph saying "omit this"). The descriptions name the contract once; the
82
+ // refusals in validateTask carry the detail when a call gets it wrong.
83
+ const delegateParameters = (cfg, background) => {
84
+ const checks = Object.keys(cfg.checks);
85
+ const profiles = cfg.profiles.map((x) => (typeof x === "string" ? x : x.name ?? x.id ?? JSON.stringify(x)));
86
+ const acceptanceForms = [
87
+ ...(checks.length > 0 ? [`{ check: ${checks.join(" | ")} }`] : []),
88
+ ...(cfg.evaluators.length > 0 ? ["{ evaluator: a configured evaluator path }"] : []),
89
+ ];
90
+ return {
91
+ type: "object",
92
+ properties: {
93
+ tasks: {
94
+ type: "array",
95
+ minItems: 1,
96
+ maxItems: 8,
97
+ items: {
98
+ type: "object",
99
+ properties: {
100
+ role: { type: "string", enum: ROLES },
101
+ task: { type: "string", minLength: 1 },
102
+ scope: { type: "array", items: { type: "string", minLength: 1 }, maxItems: 32, description: "implementer/verifier only: globs the child may write (a scoped child has no shell)" },
103
+ ...(acceptanceForms.length > 0
104
+ ? {
105
+ acceptance: {
106
+ type: "object",
107
+ properties: { check: { type: "string", minLength: 1 }, evaluator: { type: "string", minLength: 1 } },
108
+ additionalProperties: false,
109
+ description: `implementer/verifier only, one of ${acceptanceForms.join(" or ")} — never a command; run after the child completes`,
110
+ },
111
+ }
112
+ : {}),
113
+ ...(profiles.length > 0 ? { model: { type: "string", minLength: 1, description: `a model profile: ${profiles.join(", ")}` } } : {}),
114
+ after: { type: "string", minLength: 1, description: "verifier only: the 1-based index of the task whose worktree it checks" },
115
+ // 0.49.0 A: with tasks wired, a child's bound is its turn budget and
116
+ // the parent's is the join — no wall-clock kill to ask for
117
+ ...(background ? {} : { timeoutMs: { type: "integer", minimum: 1000, description: "the child's wall-clock budget, ms" } }),
92
118
  },
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"})` },
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" },
95
- timeoutMs: { type: "integer", minimum: 1000, description: "the child's wall-clock budget in milliseconds" },
119
+ required: ["role", "task"],
120
+ additionalProperties: false,
96
121
  },
97
- required: ["role", "task"],
98
- additionalProperties: false,
99
122
  },
123
+ // ADR-0058 3d: present only when the host wired tasks — without them
124
+ // the schema is byte-for-byte the foreground one. The description is
125
+ // kept verbatim: "start the tasks" marks it as the CALL's flag. The
126
+ // diet's shorter "run in the background" put it inside tasks[] in 4
127
+ // of 5 smoke legs (0 of 5 with this text; plan-prefix-diet A10).
128
+ // 0.49.0 B drops only the role limit ("explorer and reviewer only: ")
129
+ // — writers run in the background too; the subagents kit counts
130
+ // the schema refusals this wording could move (rc ≤ ctl + 2).
131
+ ...(background
132
+ ? {
133
+ background: {
134
+ type: "boolean",
135
+ description:
136
+ "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",
137
+ },
138
+ }
139
+ : {}),
100
140
  },
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
- : {}),
112
- },
113
- required: ["tasks"],
114
- additionalProperties: false,
115
- });
141
+ required: ["tasks"],
142
+ additionalProperties: false,
143
+ };
144
+ };
116
145
 
117
146
  /**
118
- * @param {{ tasks?: (sessionId: string | undefined) => any, backgroundMax?: number, backgroundMaxTurns?: number }} [host]
147
+ * @param {{ tasks?: (sessionId: string | undefined) => any, backgroundMax?: number, backgroundMaxTurns?: number, currentBinding?: (sessionId: string | undefined) => ({ profile: string | null, model?: string, reasoning?: unknown } | null) }} [host]
119
148
  * ADR-0058 3d: the host's per-session task manager (the runtime's
120
149
  * TaskManager satisfies it structurally — no dependency), the cap on live
121
150
  * background children per session, and a background child's turn budget.
151
+ * 0.49.0 C1: the conversation's live binding — the profile its session is
152
+ * bound to and the effort it runs at — so a child that names no model
153
+ * runs on the conversation's, never on the config's default.
122
154
  */
123
155
  export default async function createSubagentExtension(host = {}) {
124
156
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
125
157
  if (depth >= 1) return { name: "subagent", tools: [] }; // depth guard — no nesting
126
158
  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() };
159
+ const background =
160
+ tasksOf === undefined
161
+ ? undefined
162
+ : {
163
+ tasksOf,
164
+ max: host.backgroundMax ?? BACKGROUND_MAX,
165
+ maxTurns: host.backgroundMaxTurns ?? BACKGROUND_MAX_TURNS,
166
+ // 0.49.0 B: writers — their cap, their turn budget (128: the
167
+ // probe's 12 would cut real work), and the snapshot's size cap
168
+ writerMax: host.writerMax ?? WRITER_MAX,
169
+ writerMaxTurns: host.writerMaxTurns ?? WRITER_MAX_TURNS,
170
+ snapshotMaxBytes: host.snapshotMaxBytes ?? SNAPSHOT_MAX_BYTES,
171
+ reserved: new Map(),
172
+ };
173
+ // Plan B: ONE configuration snapshot per extension instance — the schema
174
+ // the model is shown and the executor that validates its call read the
175
+ // same values, so a session has one delegate contract (a config change
176
+ // takes effect in the next session).
177
+ const cfg = delegationConfig();
128
178
  return {
129
179
  name: "subagent",
130
180
  tools: [
131
181
  {
132
182
  name: "delegate",
133
- description: "run subagent tasks (explorer/implementer/reviewer/verifier) in child kiso processes",
134
- parameters: delegateParameters(delegationConfig(), background !== undefined),
183
+ description:
184
+ background !== undefined
185
+ ? "run subagent tasks (explorer/implementer/reviewer/verifier) in child kiso processes. Use background when the next step does not need the result; a foreground wait is bounded and continues in the background on its own."
186
+ : "run subagent tasks (explorer/implementer/reviewer/verifier) in child kiso processes",
187
+ parameters: delegateParameters(cfg, background !== undefined),
188
+ promptSnippet: "delegate — run subagent tasks",
135
189
  execute: async (input, ctx) => {
136
190
  const tasks = ((input ?? {}).tasks ?? []).slice(0, 8);
137
191
  if (tasks.length === 0) return { content: "delegate: no tasks", isError: true, errorKind: "precondition" };
@@ -140,7 +194,7 @@ export default async function createSubagentExtension(host = {}) {
140
194
  // 0.40.0: the parent's own folder (one per project), handed over
141
195
  // by the CLI; a pinned KISO_SESSIONS_DIR, or the legacy folder,
142
196
  // when no CLI said (a direct load)
143
- const sessionsDir = delegationConfig().sessionsDir ?? (process.env.KISO_SESSIONS_DIR || join(process.env.KISO_HOME ?? join(homedir(), ".kiso"), "sessions"));
197
+ const sessionsDir = cfg.sessionsDir ?? (process.env.KISO_SESSIONS_DIR || join(process.env.KISO_HOME ?? join(homedir(), ".kiso"), "sessions"));
144
198
  // P3: the loop now threads the session id through
145
199
  // ToolContext.sessionId — the discovery heuristic below is
146
200
  // kept ONLY as a fallback for direct tool use / tests.
@@ -159,17 +213,22 @@ export default async function createSubagentExtension(host = {}) {
159
213
  mkdirSync(manifestDir, { recursive: true });
160
214
  // DT-1a: every task is validated BEFORE any child runs — a refusal
161
215
  // spawns nothing (acceptance never carries a model-supplied command).
162
- const cfg = delegationConfig();
163
216
  const parentCwd = process.cwd();
217
+ const binding = bindingOf(host, ctx.sessionId);
164
218
  for (let i = 0; i < tasks.length; i += 1) {
165
- const why = validateTask(tasks[i], cfg, parentCwd, manifestDir);
219
+ const why = validateTask(tasks[i], cfg, parentCwd, manifestDir, { afterByIndex: background !== undefined });
166
220
  // DT1a-F1 (owner dogfood 2026-09-08): a refusal BEFORE any child exists is a
167
221
  // precondition — nothing ran, nothing could have partially applied — so the
168
222
  // kernel must not append the non-idempotent "side effects may have partially
169
223
  // applied" banner (loop.ts keys that banner on errorKind !== "precondition")
170
224
  if (why !== null) return { content: `delegate: refused — task ${i + 1}: ${why}`, isError: true, errorKind: "precondition" };
171
225
  }
172
- if (inBackground) return startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir });
226
+ if (inBackground) return startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
227
+ // 0.49.0 A/B: with tasks wired, every delegation is a group of
228
+ // agent tasks the call joins for up to joinMs — writers included
229
+ if (background !== undefined) {
230
+ return joinTasks({ tasks, ctx, background, joinMs: host.joinMs ?? JOIN_MS, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
231
+ }
173
232
  const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
174
233
  runChild({
175
234
  childId: `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`,
@@ -179,7 +238,8 @@ export default async function createSubagentExtension(host = {}) {
179
238
  task: task.task,
180
239
  scope: task.scope,
181
240
  acceptance: task.acceptance,
182
- model: task.model,
241
+ resolved: resolveChild(task, cfg, binding),
242
+ maxTurns: READER_ROLES.includes(task.role) ? (host.backgroundMaxTurns ?? BACKGROUND_MAX_TURNS) : undefined,
183
243
  after: task.after,
184
244
  cfg,
185
245
  sessionsDir,
@@ -196,7 +256,17 @@ export default async function createSubagentExtension(host = {}) {
196
256
  // The per-section text below is unchanged — the model's
197
257
  // view is preserved, the summary is additive.
198
258
  const summary = delegateSummary(sections, new Set(tasks.map((t) => t.role)).size);
199
- return { content: `${summary}\n${sections.map((s) => s.text).join("\n")}`, isError: sections.every((s) => s.failed) };
259
+ // C3: each child's handoff within 4 KiB, the call's within 16 KiB
260
+ let budget = GROUP_HANDOFF_BYTES;
261
+ const texts = sections.map((s) => {
262
+ if (s.handoff === undefined) return s.text;
263
+ // a failed acceptance's tail counts against its child's 4 KiB
264
+ const reserved = Math.min(s.reserved ?? 0, budget);
265
+ const body = handoffBody(s.handoff, Math.max(0, Math.min(CHILD_HANDOFF_BYTES, budget) - reserved));
266
+ budget -= body.bytes + reserved;
267
+ return `${s.text}${body.text}${s.trailer ?? ""}`;
268
+ });
269
+ return { content: `${summary}\n${texts.join("\n")}`, isError: sections.every((s) => s.failed) };
200
270
  },
201
271
  },
202
272
  ],
@@ -218,41 +288,107 @@ const refuse = (why) => ({ content: `delegate: refused — ${why}`, isError: tru
218
288
  * runs, launched by argv (no shell), with its turn budget and its result
219
289
  * file beside its task (D6).
220
290
  */
221
- async function startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir }) {
291
+ async function startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding }) {
222
292
  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
293
  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
294
  }
295
+ const r = await startTasks({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
296
+ if (r.refusal !== undefined) return r.refusal;
297
+ const n = r.started.length;
298
+ const writes = tasks.some((t) => WRITER_ROLES.includes(t.role));
299
+ const content =
300
+ `started ${n} background ${n === 1 ? "child" : "children"}: ${r.lines.join(", ")}. ` +
301
+ (writes ? "Readers read the workspace as it is while they run; writers work in a snapshot of it taken now, and each one's patch is collected when it ends. " : "They read the workspace as it is while they run. ") +
302
+ "You will be told when all of them have ended; task_stop stops one.";
303
+ return { content, isError: n === 0 };
304
+ }
305
+
306
+ /** ADR-0058 3d — start a batch as agent tasks: the cap and the reservation
307
+ * as one step (D4), then each child's durable inputs and its launch, with
308
+ * its turn budget and its result file (D6). 0.49.0 A: the background call
309
+ * and the join share it. */
310
+ async function startTasks({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding }) {
226
311
  const manager = background.tasksOf(ctx.sessionId);
227
- if (manager === undefined) return refuse("background is not available here — run the delegation in the foreground");
312
+ if (manager === undefined) return { refusal: refuse("background is not available here — run the delegation in the foreground") };
313
+ const childIdOf = (i) => `sub-${parentId}-${delegationId}-${i + 1}-${tasks[i].role}`;
314
+ // 0.49.0 B2: `after` names an implementer of THIS call by its 1-based
315
+ // index; that implementer's collection hands it its tree and starts it
316
+ const after = new Map();
317
+ for (let i = 0; i < tasks.length; i += 1) {
318
+ if (tasks[i].after === undefined) continue;
319
+ const n = Number(tasks[i].after);
320
+ if (tasks[i].role !== "verifier" || !Number.isInteger(n) || n < 1 || n > i || tasks[n - 1].role !== "implementer" || tasks[n - 1].after !== undefined) {
321
+ return { refusal: refuse(`task ${i + 1}: \`after\` must give the 1-based index of an earlier implementer in this call — its tree is collected and handed to the verifier; an earlier call's implementer has no tree left to hand over`) };
322
+ }
323
+ after.set(i, n - 1);
324
+ }
228
325
  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);
326
+ const live = manager.list().filter((t) => t.agent !== undefined && t.state.kind !== "not_run" && (t.state.kind !== "ended" || (t.agent.collect === true && t.collection === undefined))).length + (background.reserved.get(key) ?? 0);
230
327
  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`);
328
+ return { refusal: 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`) };
329
+ }
330
+ // B4: a writer holds a workspace and runs commands — its own cap
331
+ const writers = tasks.filter((t) => WRITER_ROLES.includes(t.role)).length;
332
+ const liveWriters = manager.list().filter((t) => t.agent?.collect === true && t.state.kind !== "not_run" && (t.state.kind !== "ended" || t.collection === undefined)).length;
333
+ if (writers > 0 && liveWriters + writers > background.writerMax) {
334
+ return { refusal: refuse(`${liveWriters} writers (implementer or verifier) are running in this session and the writer cap is ${background.writerMax}; this call asks for ${writers}. Do not retry — wait for some to end, or stop one with task_stop`) };
232
335
  }
233
336
  background.reserved.set(key, (background.reserved.get(key) ?? 0) + tasks.length);
234
337
  let unstarted = tasks.length;
235
338
  const release = (n) => background.reserved.set(key, Math.max(0, (background.reserved.get(key) ?? 0) - n));
236
339
  const lines = [];
237
- let failures = 0;
340
+ const started = [];
341
+ const failed = [];
342
+ const pending = [];
238
343
  try {
344
+ // B6.1: every writer that starts now gets its snapshot FIRST — all or
345
+ // nothing: a refusal (the cap, a moving tree, no repository) starts no child
346
+ const spaces = new Map();
347
+ for (let i = 0; i < tasks.length; i += 1) {
348
+ if (!WRITER_ROLES.includes(tasks[i].role) || after.has(i)) continue;
349
+ try {
350
+ spaces.set(i, snapshotWorkspace(process.cwd(), join(manifestDir, childIdOf(i), "ws"), background.snapshotMaxBytes));
351
+ } catch (err) {
352
+ for (const j of spaces.keys()) rmSync(join(manifestDir, childIdOf(j), "ws"), { recursive: true, force: true });
353
+ return { refusal: refuse(err instanceof SnapshotRefusal ? err.message : `a writer's workspace could not be built: ${msg(err)}`) };
354
+ }
355
+ }
239
356
  for (let i = 0; i < tasks.length; i += 1) {
240
357
  const task = tasks[i];
241
- const childId = `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`;
358
+ const childId = childIdOf(i);
359
+ const writer = WRITER_ROLES.includes(task.role);
360
+ const maxTurns = writer ? background.writerMaxTurns : background.maxTurns;
242
361
  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({
362
+ const resolved = resolveChild(task, cfg, binding);
363
+ const manifest = { parentId, delegationId, index: i + 1, role: task.role, startedAt: Date.now(), ...modelFieldsOf(resolved), ...(maxTurns !== undefined ? { maxTurns } : {}) };
364
+ if (after.has(i)) {
365
+ // durable now, started by its implementer's collection
366
+ writeChildInputs(manifestDir, childId, task, { ...manifest, after: after.get(i) + 1, deferred: true });
367
+ pending.push({ index: i, task, childId, behind: after.get(i) });
368
+ continue;
369
+ }
370
+ const space = spaces.get(i);
371
+ const dependents = [...after].filter(([, j]) => j === i).map(([v]) => ({ childId: childIdOf(v) }));
372
+ const inputs = writeChildInputs(
373
+ manifestDir,
374
+ childId,
375
+ task,
376
+ { ...manifest, ...(space !== undefined ? { workspace: join(manifestDir, childId, "ws"), base: space.base, head: space.head, sub: space.sub, dependents } : {}) },
377
+ space?.cwd,
378
+ );
379
+ const info = await manager.start({
245
380
  command: `${task.role}: ${task.task.split("\n")[0].slice(0, 80)}`,
246
- cwd: process.cwd(),
247
- env: childEnv(inputs.policyDir, sessionsDir),
381
+ cwd: space?.cwd ?? process.cwd(),
382
+ env: childEnv(inputs.policyDir, sessionsDir, resolved.reasoning),
248
383
  ...(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")] }),
384
+ agent: writer ? { role: task.role, session: childId, collect: true } : { role: task.role, session: childId },
385
+ exec: (dir) => ({ file: process.execPath, args: [...childArgs(bin, childId, inputs.taskPath, resolved.profile), ...(maxTurns !== undefined ? ["--max-turns", String(maxTurns)] : []), "--result-file", join(dir, "result.md")] }),
251
386
  });
252
- lines.push(`${started.id} ${task.role} (session ${childId})`);
387
+ lines.push(`${info.id} ${task.role} (session ${childId})`);
388
+ started.push({ id: info.id, task, childId, resolved });
253
389
  } catch (err) {
254
- failures += 1;
255
390
  lines.push(`task ${i + 1} (${task.role}) did not start: ${msg(err)}`);
391
+ failed.push({ task, why: msg(err) });
256
392
  }
257
393
  unstarted -= 1;
258
394
  release(1);
@@ -260,23 +396,164 @@ async function startBackground({ tasks, ctx, background, sessionsDir, parentId,
260
396
  } finally {
261
397
  release(unstarted);
262
398
  }
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 };
399
+ for (const p of pending) {
400
+ const host = started.find((s) => s.childId === childIdOf(p.behind));
401
+ lines.push(`task ${p.index + 1} verifier starts once ${host !== undefined ? host.id : `task ${p.behind + 1}`} is collected`);
402
+ }
403
+ return { manager, started, failed, pending, lines };
404
+ }
405
+
406
+ /** 0.49.0 A — the join: a reader delegation is a group of agent tasks
407
+ * from its start, and the call waits for them up to `joinMs` — the
408
+ * shell's foreground wait (ADR-0058 Amendment 10), reused. What ended
409
+ * while it waited is CLAIMED by this call (its result reports it, I2);
410
+ * what is still running continues as the group, whose notice wakes the
411
+ * parent. Nothing is killed: the budget, the person's key (detach), a
412
+ * steer and Esc all end the WAIT, never a child. */
413
+ async function joinTasks({ tasks, ctx, background, joinMs, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding }) {
414
+ const r = await startTasks({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
415
+ if (r.refusal !== undefined) return r.refusal;
416
+ const { manager, started, failed, pending } = r;
417
+ const release = new AbortController();
418
+ let reason = null;
419
+ const onEsc = () => {
420
+ reason ??= "interrupted";
421
+ release.abort();
422
+ };
423
+ if (ctx.signal?.aborted) onEsc();
424
+ else ctx.signal?.addEventListener?.("abort", onEsc);
425
+ const undetach =
426
+ typeof manager.registerDetachable === "function" && ctx.executionId !== undefined
427
+ ? manager.registerDetachable(ctx.executionId, {
428
+ startedAt: Date.now(),
429
+ detach: (by) => {
430
+ reason ??= by;
431
+ release.abort();
432
+ },
433
+ })
434
+ : () => {};
435
+ let settled;
436
+ try {
437
+ settled = await Promise.all(started.map((s) => manager.awaitSettled(s.id, "end", joinMs, { ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}), signal: release.signal, agentJoin: true })));
438
+ } finally {
439
+ undetach();
440
+ ctx.signal?.removeEventListener?.("abort", onEsc);
441
+ }
442
+ const sections = [];
443
+ const deferred = [];
444
+ const running = [];
445
+ // B2: a verifier its implementer's collection started is in the group too
446
+ const known = new Set(started.map((s) => s.id));
447
+ const late = manager.list().filter((t) => t.agent !== undefined && t.executionId === ctx.executionId && !known.has(t.id));
448
+ started.forEach((s, i) => {
449
+ const w = settled[i];
450
+ if (w.settled && w.claimed) sections.push(memberSection(s, w.info));
451
+ else if (w.settled) deferred.push(s);
452
+ else running.push(s);
453
+ });
454
+ for (const f of failed) sections.push({ failed: true, failKind: "error", text: `[subagent] ${f.task.role}: ${f.task.task}\n FAILED: did not start: ${f.why}`, toolCalls: 0 });
455
+ const summary = delegateSummary([...sections, ...[...deferred, ...running].map(() => ({ failed: false, toolCalls: 0 }))], new Set(tasks.map((t) => t.role)).size);
456
+ let budget = GROUP_HANDOFF_BYTES;
457
+ const texts = sections.map((sec) => {
458
+ if (sec.handoff === undefined) return sec.text;
459
+ const body = handoffBody(sec.handoff, Math.min(CHILD_HANDOFF_BYTES, budget));
460
+ budget -= body.bytes;
461
+ return `${sec.text}${body.text}${sec.trailer ?? ""}`;
462
+ });
463
+ // I2: an end this call could not claim is the group's to deliver
464
+ for (const d of deferred) texts.push(`[subagent] ${d.task.role}: ${d.task.task}\n ended as task ${d.id} — its result follows in the group's notice`);
465
+ const waiting = pending.filter((p) => !late.some((t) => t.agent?.session === p.childId));
466
+ if (running.length > 0) {
467
+ const why = reason === null ? `still running after ${joinMs} ms` : reason === "person" ? "moved to the background by the person" : reason === "steer" ? "moved to the background so the person's message could land" : "interrupted";
468
+ const ids = running.map((s) => s.id);
469
+ texts.push(`${why}: continued as background ${ids.length === 1 ? "task" : "tasks"} ${ids.join(", ")}; you will be told when all of them have ended; task_stop stops one.`);
470
+ }
471
+ // B2: a verifier its implementer's collection started — not a wait cut short
472
+ if (late.length > 0) {
473
+ const ids = late.map((t) => t.id);
474
+ texts.push(`started by its implementer's collection: background ${ids.length === 1 ? "task" : "tasks"} ${ids.join(", ")}; you will be told when all of them have ended; task_stop stops one.`);
475
+ }
476
+ for (const p of waiting) texts.push(`[subagent] verifier: ${p.task.task}\n starts once task ${p.behind + 1}'s implementer is collected, in its tree (base plus its patch); its result follows in the group's notice`);
477
+ const isError = running.length === 0 && late.length === 0 && waiting.length === 0 && deferred.length === 0 && sections.length > 0 && sections.every((sec) => sec.failed);
478
+ return { content: `${summary}\n${texts.join("\n")}`, isError };
479
+ }
480
+
481
+ /** 0.49.0 A — a joined child's section, from its task directory: the
482
+ * same head and handoff a foreground child's has (C3), read from its
483
+ * result record (I5/I6) and its output. */
484
+ function memberSection(s, info) {
485
+ const dir = dirname(info.outputPath);
486
+ const { record, full, answer } = readChildResult(dir);
487
+ const exitCode = info.state?.kind === "ended" ? info.state.exitCode : null;
488
+ const status = record !== null ? record.outcome : "failed";
489
+ const failed = record === null || record.outcome === "failed";
490
+ const toolCalls = typeof full?.toolCalls === "number" ? full.toolCalls : 0;
491
+ const ranOn = typeof full?.profile === "string" ? full.profile : typeof full?.model === "string" ? full.model : (s.resolved.profile ?? "unknown");
492
+ let text = `[subagent] ${s.task.role}: ${s.task.task}\n status: ${status} · model: ${ranOn}${s.resolved.source === "environment" ? " (default)" : ""} · verification: none · tools: ${toolCalls} · task ${s.id}`;
493
+ if (record === null) text += `\n FAILED: the child ${exitCode !== null && exitCode !== 0 ? `exited with code ${exitCode} and ` : ""}left no result record`;
494
+ else if (record.outcome === "failed") text += `\n FAILED: the child's run ended with ${typeof full?.endedBy === "string" ? full.endedBy : "a failure"}`;
495
+ const tail = rawTail(info.outputPath, HANDOFF_TAIL_BYTES);
496
+ const unresolved = record === null ? null : parseUnresolved(answer);
497
+ let trailer = unresolved === null && record !== null && answer.trim() !== "" ? "\n unresolved: not reported" : "";
498
+ let writerFailed = false;
499
+ if (s.task.role === "implementer") {
500
+ const w = writerLines(dir, info);
501
+ trailer += w.text;
502
+ writerFailed = w.failed;
503
+ }
504
+ const all = failed || writerFailed;
505
+ return { failed: all, ...(all ? { failKind: "error" } : {}), text, handoff: { record, answer, tail: tail.text, tailCut: tail.cut, resultPath: join(dir, "result.md") }, trailer, toolCalls };
506
+ }
507
+
508
+ /** B — what an implementer's handoff adds: its patch (by path, never
509
+ * inline), the base it was written against, the child's acceptance, the
510
+ * command that adopts it, and what became of a verifier behind it. */
511
+ function writerLines(dir, info) {
512
+ const c = info.collection;
513
+ if (c?.outcome === "failed") return { text: `\n FAILED: collecting its changes: ${c.reason ?? "unknown"}`, failed: true };
514
+ let patch = null;
515
+ try {
516
+ patch = JSON.parse(readFileSync(join(dir, "patch.json"), "utf8"));
517
+ } catch {
518
+ return { text: "\n FAILED: its patch record is missing", failed: true };
519
+ }
520
+ if (patch.files.length === 0) return { text: "\n no changes", failed: false };
521
+ let acc = "none";
522
+ try {
523
+ const v = JSON.parse(readFileSync(join(dir, "acceptance.json"), "utf8"));
524
+ acc = v.passed ? "PASSED" : "FAILED";
525
+ } catch {
526
+ // no acceptance named, or the child did not complete
527
+ }
528
+ const files = patch.files.map((f) => f.path);
529
+ const bin = process.env.KISO_SUBAGENT_BIN ?? process.argv[1];
530
+ let text = `\n patch: ${join(dir, "patch.diff")} (${files.length} ${files.length === 1 ? "file" : "files"}: ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}) · base ${String(patch.base).slice(0, 7)} · child acceptance: ${acc}`;
531
+ text += patch.applyable ? `\n apply: node ${shellQuote(bin)} apply-patch ${shellQuote(dir)}` : `\n the child was stopped: the patch is partial — apply only with: node ${shellQuote(bin)} apply-patch ${shellQuote(dir)} --allow-partial`;
532
+ try {
533
+ const deps = JSON.parse(readFileSync(join(dir, "dependents.json"), "utf8"));
534
+ for (const d of Object.values(deps)) text += d.taskId !== undefined ? `\n its verifier runs as task ${d.taskId}, in base plus this patch` : `\n its verifier was skipped: ${d.skipped}`;
535
+ } catch {
536
+ // no verifier behind it
537
+ }
538
+ return { text, failed: acc === "FAILED" };
539
+ }
540
+
541
+ /** A path for the shell: single-quoted when it needs quoting. */
542
+ function shellQuote(p) {
543
+ return /^[\w@%+=:,./-]+$/.test(p) ? p : `'${String(p).replaceAll("'", "'\\''")}'`;
268
544
  }
269
545
 
270
546
  /** The durable inputs of a background child: the task file (the fixed
271
547
  * UNRESOLVED trailer included), its role policy, its manifest — fsynced
272
548
  * with their directories before the task is planned. */
273
- function writeChildInputs(manifestDir, childId, task, manifest) {
549
+ function writeChildInputs(manifestDir, childId, task, manifest, policyRoot) {
274
550
  const taskPath = join(manifestDir, `${childId}.task`);
275
551
  const policyDir = join(manifestDir, `${childId}.policy`);
276
552
  mkdirSync(policyDir, { recursive: true });
277
- writeDurable(join(policyDir, "policy.mjs"), rolePolicyContent(task.role, undefined));
553
+ // DT-1a R2.1: a scoped writer's policy checks every write against its globs, from its own root
554
+ writeDurable(join(policyDir, "policy.mjs"), rolePolicyContent(task.role, task.scope !== undefined && policyRoot !== undefined ? { root: policyRoot, globs: task.scope } : undefined));
278
555
  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`);
556
+ writeDurable(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task: task.task, background: true, ...(task.scope !== undefined ? { scope: task.scope } : {}), ...(task.acceptance !== undefined ? { acceptance: task.acceptance } : {}) })}\n`);
280
557
  fsyncPath(policyDir);
281
558
  fsyncPath(manifestDir);
282
559
  return { taskPath, policyDir };
@@ -341,13 +618,13 @@ function runLimited(items, limit, fn) {
341
618
  return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results);
342
619
  }
343
620
 
344
- async function runChild({ childId, role, task, scope, acceptance, model, after, cfg, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
621
+ async function runChild({ childId, role, task, scope, acceptance, resolved, maxTurns, after, cfg, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
345
622
  // CX-1 F6: the manifest binds this invocation to its child session
346
623
  // BEFORE anything runs — the durable record of "which run is mine".
347
624
  // DT-1a: the contract's inputs ride in it, verbatim.
348
625
  const startedAt = manifest?.startedAt ?? Date.now();
349
626
  if (manifestDir !== undefined) {
350
- writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task, ...(scope !== undefined ? { scope } : {}), ...(acceptance !== undefined ? { acceptance } : {}), ...(model !== undefined ? { model } : {}), ...(after !== undefined ? { after } : {}) })}\n`, "utf8");
627
+ writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task, ...(scope !== undefined ? { scope } : {}), ...(acceptance !== undefined ? { acceptance } : {}), ...modelFieldsOf(resolved), ...(after !== undefined ? { after } : {}) })}\n`, "utf8");
351
628
  }
352
629
  // Isolation (DT-1a R2.2): implementer → its own detached worktree from
353
630
  // the parent's HEAD (the parent's UNCOMMITTED changes are not visible —
@@ -404,10 +681,17 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
404
681
  // the fixed UNRESOLVED instruction the result parser reads back.
405
682
  const taskPath = join(manifestDir ?? policyDir, `${childId}.task`);
406
683
  writeFileSync(taskPath, `${task}\n\n${UNRESOLVED_INSTRUCTION}\n`, "utf8");
407
- const { code, stdout, killed, unconfirmed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal, model, sessionsDir);
408
- const extraction = await extractChildResult(sessionsDir, childId, `exit ${code}\n${stdout}`);
409
- const status = killed === "timeout" ? "timeout" : killed === "abort" ? "killed" : code !== 0 && extraction.outcome === "missing" ? "spawn-failed" : extraction.outcome;
410
- let failed = code !== 0 || killed !== null || extraction.failed;
684
+ // 0.49.0 C2/C3: the child's own directory — its result record, its
685
+ // answer and its printed output, as a background child's task has
686
+ const childDir = join(manifestDir ?? tmpdir(), childId);
687
+ mkdirSync(childDir, { recursive: true });
688
+ const { code, killed, unconfirmed, outputPath } = await runProcess({ childId, bin, cwd: childCwd, policyDir, taskPath, timeout, signal, resolved, sessionsDir, childDir, maxTurns });
689
+ // I5/I6: the result is what the child's record commits — never
690
+ // inferred from its printed output or from its session's runs
691
+ const { record, full, answer } = readChildResult(childDir);
692
+ const status = killed === "timeout" ? "timeout" : killed === "abort" ? "killed" : record !== null ? record.outcome : code !== 0 ? "failed" : "no-result";
693
+ let failed = code !== 0 || killed !== null || record === null || record.outcome === "failed";
694
+ const toolCalls = typeof full?.toolCalls === "number" ? full.toolCalls : 0;
411
695
  const lines = [];
412
696
  // Windows P6: a kill that did not take is never reported as a kill
413
697
  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)";
@@ -415,12 +699,12 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
415
699
  lines.push(` FAILED: timed out after ${timeout}ms${killNote}`);
416
700
  } else if (killed === "abort") {
417
701
  lines.push(` FAILED: aborted by the parent run${killNote}`);
418
- } else if (code !== 0) {
419
- lines.push(` FAILED: the child exited with code ${code}\n${stdout}`);
420
- } else if (extraction.failed) {
421
- lines.push(` FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`);
702
+ } else if (record === null) {
703
+ lines.push(` FAILED: the child ${code !== 0 ? `exited with code ${code} and ` : ""}left no result record`);
704
+ } else if (record.outcome === "failed") {
705
+ lines.push(` FAILED: the child's run ended with ${typeof full?.endedBy === "string" ? full.endedBy : "a failure"}`);
422
706
  }
423
- const unresolved = parseUnresolved(extraction.text);
707
+ const unresolved = record === null ? null : parseUnresolved(answer);
424
708
  // the collection (implementers only — a verifier's worktree is a
425
709
  // throwaway copy; its changes are not its result)
426
710
  let changedFiles = null;
@@ -459,6 +743,7 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
459
743
  else verification = await runAcceptance(acceptance, cfg, worktree ?? childCwd, baseRev, timeout, signal);
460
744
  if (verification.passed === false) failed = true;
461
745
  }
746
+ const tail = rawTail(outputPath, HANDOFF_TAIL_BYTES);
462
747
  const endedAt = Date.now();
463
748
  const result = {
464
749
  identity: { ...manifest, childId, role, startedAt, endedAt },
@@ -466,7 +751,7 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
466
751
  task,
467
752
  ...(scope !== undefined ? { scope } : {}),
468
753
  ...(acceptance !== undefined ? { acceptance } : {}),
469
- ...(model !== undefined ? { model } : {}),
754
+ ...modelFieldsOf(resolved),
470
755
  ...(after !== undefined ? { after } : {}),
471
756
  worktree,
472
757
  baseRev,
@@ -476,39 +761,45 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
476
761
  verification,
477
762
  ...(unconfirmed.length > 0 ? { unconfirmed } : {}),
478
763
  unresolved,
479
- answer: extraction.text,
480
- usage: extraction.usage,
481
- toolCalls: extraction.toolCalls,
764
+ answer: record === null ? "" : answer.trimEnd(),
765
+ usage: full?.usage ?? NO_USAGE,
766
+ toolCalls,
482
767
  failed,
483
- diag: failed ? extraction.diag : "",
768
+ diag: failed ? tail.text : "",
769
+ childDir,
484
770
  };
485
771
  if (manifestDir !== undefined) writeFileSync(join(manifestDir, `${childId}.result.json`), `${JSON.stringify(result)}\n`, "utf8");
486
- // The section (what the model reads) — rendered from the result.
772
+ // The section (what the model reads) — its head, then the child's
773
+ // handoff (C3: the shared format, within the call's budget), then
774
+ // what only a writer has.
487
775
  const verdict = verification === null ? "none" : verification.skipped !== undefined ? `SKIPPED (${verification.skipped})` : verification.passed ? "PASSED" : "FAILED";
488
- let text = `[subagent] ${role}: ${task}\n status: ${status} · verification: ${verdict}${changedFiles !== null ? ` · files changed: ${changedFiles.length}` : ""} · tools: ${extraction.toolCalls}`;
776
+ const ranOn = typeof full?.profile === "string" ? full.profile : typeof full?.model === "string" ? full.model : (resolved.profile ?? "unknown");
777
+ let text = `[subagent] ${role}: ${task}\n status: ${status} · model: ${ranOn}${resolved.source === "environment" ? " (default)" : ""} · verification: ${verdict}${changedFiles !== null ? ` · files changed: ${changedFiles.length}` : ""} · tools: ${toolCalls}`;
489
778
  if (scope !== undefined) text += `\n scoped: no shell · writes only under ${scope.join(", ")}`;
490
779
  if (baseRev !== null) text += `\n child saw HEAD ${baseRev.slice(0, 7)}; the parent's uncommitted changes were not visible`;
780
+ let reserved = 0;
491
781
  if (verification !== null && verification.skipped === undefined) {
492
782
  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
783
  text += `\n verification: ${verification.kind} ${verification.passed ? "PASSED" : "FAILED"} · exit ${exit} · ${verification.durationMs}ms · ${verification.kind === "check" ? verification.command : verification.evaluator}`;
494
- if (!verification.passed && verification.tail !== "") text += `\n${verification.tail}`;
784
+ if (!verification.passed && verification.tail !== "") {
785
+ text += `\n${verification.tail}`;
786
+ reserved = Buffer.byteLength(verification.tail);
787
+ }
495
788
  }
496
789
  for (const l of lines) text += `\n${l}`;
497
- if (extraction.text !== "") text += `\n${extraction.text}`;
498
- text += unresolved === null ? "\n unresolved: not reported" : unresolved.length === 0 ? "\n unresolved: none" : `\n unresolved:\n${unresolved.map((u) => ` - ${u}`).join("\n")}`;
790
+ let trailer = "";
791
+ if (unresolved === null && record !== null && answer.trim() !== "") trailer += "\n unresolved: not reported";
499
792
  if (collection !== null) {
500
793
  if (collection.kind === "collected") {
501
- text += `\n diff:\n${collection.stat}\n patch: ${patchPath}`;
502
- if (collection.bytes <= INLINE_PATCH_BYTES) text += `\n${readFileSync(patchPath, "utf8")}`;
503
- else text += `\n (patch is ${collection.bytes} bytes — read it with the shell: cat ${patchPath}, or git -C ${worktree} diff ${baseRev})`;
504
- text += `\n worktree kept at: ${worktree}`;
794
+ trailer += `\n diff:\n${collection.stat}\n patch: ${patchPath}\n worktree kept at: ${worktree}`;
505
795
  } else if (collection.kind === "failed") {
506
- text += `\n FAILED: collecting the worktree's changes: ${collection.reason}${collection.partialPath !== undefined ? ` (partial patch at ${collection.partialPath})` : ""}\n worktree kept at: ${worktree}`;
796
+ trailer += `\n FAILED: collecting the worktree's changes: ${collection.reason}${collection.partialPath !== undefined ? ` (partial patch at ${collection.partialPath})` : ""}\n worktree kept at: ${worktree}`;
507
797
  }
508
798
  } 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`;
799
+ trailer += `\n worktree kept at: ${worktree} — the child may still be writing to it; nothing was collected`;
510
800
  }
511
- return { failed, ...(failed ? { failKind: failKindOf(status, verification) } : {}), text, toolCalls: extraction.toolCalls };
801
+ const handoff = { record, answer, tail: tail.text, tailCut: tail.cut, resultPath: join(childDir, "result.md") };
802
+ return { failed, ...(failed ? { failKind: failKindOf(status, verification) } : {}), text, handoff, reserved, trailer, toolCalls };
512
803
  } finally {
513
804
  rmSync(policyDir, { recursive: true, force: true });
514
805
  if (worktree !== null && ownsWorktree && !keepWorktree) removeWorktree(parentCwd, worktree);
@@ -545,8 +836,11 @@ export function delegateSummary(sections, roles) {
545
836
  return `summary: ${n} task${n === 1 ? "" : "s"} · ${toolCalls} tool calls · ${roles} role${roles === 1 ? "" : "s"} · ${failedSections.length} failed${kinds.length > 0 ? ` (${kinds.join(", ")})` : ""}`;
546
837
  }
547
838
 
548
- /** DT-1a: the fixed trailer every task file ends with — the parser reads the section back. */
549
- export const UNRESOLVED_INSTRUCTION = 'When you finish, end your reply with a section titled UNRESOLVED listing what you could not do or verify, one item per line starting with "- ", or the single word none.';
839
+ /** DT-1a: the fixed trailer every task file ends with — the parser reads
840
+ * the section back. 0.49.0 C3: it states the handoff contract too — the
841
+ * child's final answer is all the parent reads of its run. */
842
+ export const UNRESOLVED_INSTRUCTION =
843
+ 'Your final answer is a handoff to the parent: the conclusion first, only the evidence needed to act on it, no raw command output or large diffs. End it with a section titled UNRESOLVED listing what you could not do or verify, one item per line starting with "- ", or the single word none.';
550
844
 
551
845
  /** DT-1a: what a delegated task may NAME — the parent CLI hands the configured
552
846
  * checks and model profiles through the environment. Absent = nothing configured. */
@@ -559,9 +853,11 @@ function delegationConfig() {
559
853
  evaluators: Array.isArray(parsed.evaluators) ? parsed.evaluators.filter((p) => typeof p === "string") : [],
560
854
  profiles: parsed.profiles ?? [],
561
855
  sessionsDir: typeof parsed.sessionsDir === "string" && parsed.sessionsDir !== "" ? parsed.sessionsDir : undefined,
856
+ // 0.49.0 C1: the user's subagents.model (validated by the host)
857
+ subagentsModel: typeof parsed.subagentsModel === "string" && parsed.subagentsModel !== "" ? parsed.subagentsModel : undefined,
562
858
  };
563
859
  } catch {
564
- return { checks: {}, evaluators: [], profiles: [], sessionsDir: undefined };
860
+ return { checks: {}, evaluators: [], profiles: [], sessionsDir: undefined, subagentsModel: undefined };
565
861
  }
566
862
  }
567
863
 
@@ -576,7 +872,7 @@ function artifactDir(home, sessionsDir) {
576
872
  }
577
873
 
578
874
  /** DT-1a: every task's inputs are judged BEFORE any child runs; a string is the refusal. */
579
- export function validateTask(task, cfg, parentCwd, manifestDir) {
875
+ export function validateTask(task, cfg, parentCwd, manifestDir, opts = {}) {
580
876
  if (task === null || typeof task !== "object") return "a task must be an object";
581
877
  if (task.role === "tester") return 'unknown role "tester" — it is called "verifier" since 0.46.0';
582
878
  if (!ROLES.includes(task.role)) return `unknown role ${JSON.stringify(task.role)}`;
@@ -611,7 +907,11 @@ export function validateTask(task, cfg, parentCwd, manifestDir) {
611
907
  }
612
908
  }
613
909
  if (task.model !== undefined && !cfg.profiles.includes(task.model)) return `refused: unknown model profile ${JSON.stringify(task.model)} (configured: ${cfg.profiles.join(", ") || "none"})`;
614
- if (task.after !== undefined) {
910
+ // 0.49.0 B2: with tasks wired, `after` is an index into this call — the
911
+ // batch checks it (startTasks); an earlier call's tree no longer exists
912
+ if (task.after !== undefined && opts.afterByIndex === true) {
913
+ if (task.role !== "verifier") return "refused: `after` is for verifier tasks";
914
+ } else if (task.after !== undefined) {
615
915
  if (task.role !== "verifier") return "refused: `after` is for verifier tasks";
616
916
  let prior;
617
917
  try {
@@ -873,11 +1173,16 @@ export function childArgs(bin, childId, taskPath, model) {
873
1173
  }
874
1174
 
875
1175
  /** 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) {
1176
+ * policy and the sessions folder (the foreground and background share it).
1177
+ * 0.49.0 C1: a child that runs on the conversation's profile runs at the
1178
+ * conversation's effort too (KISO_CHILD_REASONING) — and only that child:
1179
+ * a profile the task or subagents.model named starts at its own effort. */
1180
+ function childEnv(policyDir, sessionsDir, reasoning) {
878
1181
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
1182
+ const { KISO_CHILD_REASONING: _inherited, ...parent } = process.env;
879
1183
  return {
880
- ...process.env,
1184
+ ...(reasoning !== undefined && reasoning !== null ? { KISO_CHILD_REASONING: JSON.stringify(reasoning) } : {}),
1185
+ ...parent,
881
1186
  KISO_SUBAGENT_DEPTH: String(depth + 1),
882
1187
  KISO_EXTENSIONS_DIR: policyDir,
883
1188
  // 0.40.0: the child writes its log beside its parent's — the
@@ -893,19 +1198,50 @@ function childEnv(policyDir, sessionsDir) {
893
1198
  };
894
1199
  }
895
1200
 
896
- function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model, sessionsDir) {
897
- const child = spawn(process.execPath, childArgs(bin, childId, taskPath, model), {
1201
+ /** A foreground child's process. 0.49.0 C3: what it prints — stdout and
1202
+ * stderr — streams into its output.log; nothing of it accumulates in
1203
+ * memory, so a child that prints without bound costs the parent nothing.
1204
+ * C2: a reader runs under its turn budget; every child writes its result
1205
+ * record (--result-file), the parent's only source for its result. */
1206
+ function runProcess({ childId, bin, cwd, policyDir, taskPath, timeout, signal, resolved, sessionsDir, childDir, maxTurns }) {
1207
+ const args = [...childArgs(bin, childId, taskPath, resolved.profile), ...(maxTurns !== undefined ? ["--max-turns", String(maxTurns)] : []), "--result-file", join(childDir, "result.md")];
1208
+ const child = spawn(process.execPath, args, {
898
1209
  cwd,
899
- env: childEnv(policyDir, sessionsDir),
1210
+ env: childEnv(policyDir, sessionsDir, resolved.reasoning),
900
1211
  ...ownGroup(),
901
- stdio: ["pipe", "pipe", "inherit"],
1212
+ stdio: ["pipe", "pipe", "pipe"],
902
1213
  });
903
1214
  child.stdin.end(); // CX-1 F5: nothing rides stdin — the task is the file
904
- let stdout = "";
905
- child.stdout.on("data", (d) => {
906
- stdout += String(d);
1215
+ const outputPath = join(childDir, "output.log");
1216
+ let fd = openSync(outputPath, "a");
1217
+ let open = 2;
1218
+ let drained;
1219
+ const allClosed = new Promise((r) => {
1220
+ drained = r;
1221
+ });
1222
+ for (const stream of [child.stdout, child.stderr]) {
1223
+ stream.on("data", (d) => {
1224
+ if (fd === -1) return;
1225
+ try {
1226
+ writeSync(fd, d);
1227
+ } catch {
1228
+ // best effort: the output is diagnostics, never the result
1229
+ }
1230
+ });
1231
+ stream.on("close", () => {
1232
+ open -= 1;
1233
+ if (open === 0) drained();
1234
+ });
1235
+ }
1236
+ return superviseExit(child, timeout, signal).then(async ({ code, killed, unconfirmed }) => {
1237
+ // the pipes drain after the exit; a child let go (unconfirmed) has
1238
+ // had them destroyed — the wait is bounded either way
1239
+ await Promise.race([allClosed, new Promise((r) => setTimeout(r, 1_000))]);
1240
+ const closing = fd;
1241
+ fd = -1;
1242
+ closeSync(closing);
1243
+ return { code: code ?? -1, killed, unconfirmed, outputPath };
907
1244
  });
908
- return superviseExit(child, timeout, signal).then(({ code, killed, unconfirmed }) => ({ code: code ?? -1, stdout, killed, unconfirmed }));
909
1245
  }
910
1246
 
911
1247
  /** The role policy: read-only for explorer/reviewer, the full six for
@@ -1045,9 +1381,6 @@ function finalText(events) {
1045
1381
  return text;
1046
1382
  }
1047
1383
 
1048
- /** CX-1 F2: a patch body this size or smaller rides inline in the section;
1049
- * larger ones are named by path (the parent reads them with the shell). */
1050
- const INLINE_PATCH_BYTES = 64 * 1024;
1051
1384
 
1052
1385
  /** CX-1 F2: the implementer's changes against the BASE revision, streamed to
1053
1386
  * a file — intent-to-add first so NEW files are part of the diff. Three
@@ -1111,6 +1444,387 @@ function removeWorktree(parentCwd, worktree) {
1111
1444
  }
1112
1445
  }
1113
1446
 
1447
+ // ── 0.49.0 B: writers — a private snapshot, the parent's collection ──────
1448
+ // A writer owns an isolated workspace; adoption is a separate, crash-honest
1449
+ // effect (the plan's third sentence). Its end is its COLLECTION: the task
1450
+ // is started with agent.collect, and the host's TaskManager calls
1451
+ // collectWriter once its process has ended; only `collected` ends it.
1452
+
1453
+ const WRITER_ROLES = ["implementer", "verifier"];
1454
+ const WRITER_MAX = 4;
1455
+ const SNAPSHOT_MAX_BYTES = 50 * 1024 * 1024;
1456
+
1457
+ class SnapshotRefusal extends Error {}
1458
+
1459
+ const gitOut = (args, opts = {}) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024, ...opts }).trim();
1460
+
1461
+ /** I3 — the dirty part of the working tree, in git's machine format
1462
+ * (porcelain v2, NUL-separated: names with spaces, quotes or newlines
1463
+ * survive): path → its type, the sha256 of its bytes and its exec bit, or
1464
+ * null when it is deleted. Ignored paths are never listed. --no-optional-
1465
+ * locks: not even the person's index stat cache is refreshed. */
1466
+ function workingManifest(root, skip = []) {
1467
+ const raw = execFileSync("git", ["-C", root, "--no-optional-locks", "status", "--porcelain=v2", "-z", "--untracked-files=all", "--no-renames"], { maxBuffer: 256 * 1024 * 1024 }).toString("utf8");
1468
+ const paths = [];
1469
+ for (const e of raw.split("\0")) {
1470
+ if (e.startsWith("? ")) paths.push(e.slice(2));
1471
+ else if (e.startsWith("1 ")) paths.push(e.split(" ").slice(8).join(" "));
1472
+ else if (e.startsWith("u ")) paths.push(e.split(" ").slice(10).join(" "));
1473
+ }
1474
+ const entries = new Map();
1475
+ let bytes = 0;
1476
+ for (const path of paths.sort()) {
1477
+ const full = join(root, path);
1478
+ // kiso's own home and the writers' workspaces are never part of a
1479
+ // snapshot — a home inside the repository would copy itself
1480
+ if (skip.some((r) => full === r || full.startsWith(`${r}${sep}`))) continue;
1481
+ let st;
1482
+ try {
1483
+ st = lstatSync(full);
1484
+ } catch {
1485
+ entries.set(path, null); // deleted in the working tree
1486
+ continue;
1487
+ }
1488
+ if (st.isSymbolicLink()) {
1489
+ const target = readlinkSync(full);
1490
+ entries.set(path, { type: "link", sha256: createHash("sha256").update(target).digest("hex"), exec: false });
1491
+ } else if (st.isFile()) {
1492
+ const buf = readFileSync(full);
1493
+ bytes += buf.length;
1494
+ entries.set(path, { type: "file", sha256: createHash("sha256").update(buf).digest("hex"), exec: (st.mode & 0o111) !== 0 });
1495
+ }
1496
+ // a directory here is a submodule (gitlink): not part of a snapshot,
1497
+ // as a worktree never initialised one
1498
+ }
1499
+ return { entries, bytes };
1500
+ }
1501
+
1502
+ const sameManifest = (a, b) => a.entries.size === b.entries.size && [...a.entries].every(([k, v]) => JSON.stringify(b.entries.get(k)) === JSON.stringify(v) && b.entries.has(k));
1503
+
1504
+ /** B6.1 — a writer's workspace: a task-private repository that borrows the
1505
+ * person's objects read-only (`clone --shared`), checked out at HEAD, with
1506
+ * the working tree's dirty files copied in; the snapshot is committed in
1507
+ * the private repository and is the writer's base. Nothing is written into
1508
+ * the person's repository. I3: accepted only if HEAD and the manifest are
1509
+ * unchanged across the capture — else once more, then a refusal. Over the
1510
+ * cap: a refusal, never HEAD instead. */
1511
+ export function snapshotWorkspace(parentCwd, ws, maxBytes = SNAPSHOT_MAX_BYTES) {
1512
+ let root;
1513
+ let h0;
1514
+ try {
1515
+ root = gitOut(["-C", parentCwd, "rev-parse", "--show-toplevel"]);
1516
+ h0 = gitOut(["-C", root, "rev-parse", "--verify", "HEAD"]);
1517
+ } catch (err) {
1518
+ throw new SnapshotRefusal(`a writer needs a git repository with a commit: ${msg(err)}`);
1519
+ }
1520
+ const sub = relative(realpathSync(root), realpathSync(parentCwd));
1521
+ const real = (p) => {
1522
+ try {
1523
+ return realpathSync(p);
1524
+ } catch {
1525
+ return resolve(p);
1526
+ }
1527
+ };
1528
+ // both sides real paths: git's top level is, and these are made so. The
1529
+ // writers' workspaces live under kiso's home; `ws` itself is named for a
1530
+ // caller that puts it elsewhere
1531
+ const skip = [real(process.env.KISO_HOME ?? join(homedir(), ".kiso")), real(ws)];
1532
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1533
+ const m0 = workingManifest(root, skip);
1534
+ if (m0.bytes > maxBytes) {
1535
+ const size = (n) => (n >= 1024 * 1024 ? `${Math.floor(n / (1024 * 1024))} MiB` : `${n} bytes`);
1536
+ throw new SnapshotRefusal(`the working-tree snapshot would copy ${size(m0.bytes)}, over ${size(maxBytes)}; commit, stash or shrink the workspace, or raise the host's limit`);
1537
+ }
1538
+ rmSync(ws, { recursive: true, force: true });
1539
+ mkdirSync(dirname(ws), { recursive: true });
1540
+ execFileSync("git", ["clone", "-q", "--shared", "--no-checkout", root, ws], { stdio: "ignore" });
1541
+ execFileSync("git", ["-C", ws, "checkout", "-q", "--detach", h0], { stdio: "ignore" });
1542
+ let stable = true;
1543
+ for (const [path, e] of m0.entries) {
1544
+ const from = join(root, path);
1545
+ const to = join(ws, path);
1546
+ if (e === null) {
1547
+ rmSync(to, { force: true });
1548
+ continue;
1549
+ }
1550
+ mkdirSync(dirname(to), { recursive: true });
1551
+ rmSync(to, { force: true });
1552
+ if (e.type === "link") {
1553
+ symlinkSync(readlinkSync(from), to);
1554
+ continue;
1555
+ }
1556
+ // copy what was hashed: a file that changed since the manifest is unstable
1557
+ const buf = readFileSync(from);
1558
+ if (createHash("sha256").update(buf).digest("hex") !== e.sha256) stable = false;
1559
+ writeFileSync(to, buf);
1560
+ chmodSync(to, e.exec ? 0o755 : 0o644);
1561
+ }
1562
+ // test-only: a file the gate touches between the copy and the re-read
1563
+ if (process.env.KISO_TEST_SNAPSHOT_TOUCH !== undefined && (attempt === 0 || process.env.KISO_TEST_SNAPSHOT_TOUCH_ALWAYS === "1")) appendFileSync(process.env.KISO_TEST_SNAPSHOT_TOUCH, "x");
1564
+ const h1 = gitOut(["-C", root, "rev-parse", "--verify", "HEAD"]);
1565
+ if (stable && h1 === h0 && sameManifest(m0, workingManifest(root, skip))) {
1566
+ execFileSync("git", ["-C", ws, "add", "-A"], { stdio: "ignore" });
1567
+ execFileSync("git", ["-C", ws, "-c", "user.name=kiso", "-c", "user.email=kiso@localhost", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "--no-verify", "-m", "kiso: the working tree at dispatch"], { stdio: "ignore" });
1568
+ return { root, head: h0, base: gitOut(["-C", ws, "rev-parse", "HEAD"]), cwd: join(ws, sub), sub };
1569
+ }
1570
+ h0 = h1;
1571
+ }
1572
+ rmSync(ws, { recursive: true, force: true });
1573
+ throw new SnapshotRefusal("the workspace changed while the snapshot was being captured (twice); let the edits settle and delegate again");
1574
+ }
1575
+
1576
+ /** B — collect a writer that ended: called once by the host's TaskManager
1577
+ * (its `collect` option), live or at the next open. Each step is keyed by
1578
+ * its own record file and skipped when it is there, so a kill -9 resumes
1579
+ * where it stopped; `collected` is written LAST. An implementer's result is
1580
+ * its patch (and the child's acceptance); a verifier's writes are never
1581
+ * collected — its result is its report. */
1582
+ export async function collectWriter(info, manager) {
1583
+ const cfg = delegationConfig();
1584
+ const home = process.env.KISO_HOME ?? join(homedir(), ".kiso");
1585
+ const sessionsDir = cfg.sessionsDir ?? (process.env.KISO_SESSIONS_DIR || join(home, "sessions"));
1586
+ const manifestDir = artifactDir(home, sessionsDir);
1587
+ const childId = info.agent?.session;
1588
+ if (manifestDir === null || typeof childId !== "string") throw new Error("no manifest for this writer");
1589
+ const man = JSON.parse(readFileSync(join(manifestDir, `${childId}.json`), "utf8"));
1590
+ const taskDir = dirname(info.outputPath);
1591
+ const journal = join(taskDir, "journal.jsonl");
1592
+ const done = (outcome, reason) => appendFileSync(journal, `${JSON.stringify({ type: "collected", ts: Date.now(), outcome, ...(reason !== undefined ? { reason } : {}) })}\n`);
1593
+ const ws = man.workspace;
1594
+ const stopped = info.stoppedBy !== undefined;
1595
+ const { record } = readChildResult(taskDir);
1596
+ const completed = record !== null && record.outcome === "completed" && !stopped;
1597
+ if (man.role !== "implementer") {
1598
+ rmSync(ws, { recursive: true, force: true });
1599
+ return done(stopped ? "stopped" : "collected");
1600
+ }
1601
+ // 1. the patch, its index and each changed file's two versions — the one
1602
+ // step that needs the workspace: once it is recorded, a resume after a
1603
+ // crash past the workspace's removal goes straight on to `collected`
1604
+ const patchJson = join(taskDir, "patch.json");
1605
+ if (!existsSync(patchJson)) {
1606
+ if (typeof ws !== "string" || !existsSync(ws)) return done("failed", `the workspace is gone before its patch was collected: ${ws}`);
1607
+ try {
1608
+ writePatch(ws, man.base, taskDir, stopped, man.acceptance);
1609
+ } catch (err) {
1610
+ return done("failed", `collecting the patch: ${msg(err)}; the workspace is kept at ${ws}`);
1611
+ }
1612
+ }
1613
+ const patch = JSON.parse(readFileSync(patchJson, "utf8"));
1614
+ // 2. the child's acceptance — after a completed child only (DT-1a R2.3)
1615
+ const accPath = join(taskDir, "acceptance.json");
1616
+ if (man.acceptance !== undefined && completed && patch.files.length > 0 && !existsSync(accPath) && existsSync(ws)) {
1617
+ const v = await runAcceptance(man.acceptance, cfg, ws, man.base, TIMEOUT_MS, undefined);
1618
+ writeAtomic(accPath, `${JSON.stringify(v)}\n`);
1619
+ }
1620
+ // 3. a verifier `after` it: handed this tree (base + patch), started in
1621
+ // its group BEFORE `collected`, so the group never looks complete between
1622
+ const depsPath = join(taskDir, "dependents.json");
1623
+ const deps = existsSync(depsPath) ? JSON.parse(readFileSync(depsPath, "utf8")) : {};
1624
+ for (const [i, dep] of (man.dependents ?? []).entries()) {
1625
+ if (deps[dep.childId] !== undefined) continue;
1626
+ const accepted = existsSync(accPath) ? JSON.parse(readFileSync(accPath, "utf8")).passed !== false : true;
1627
+ if (!completed || patch.files.length === 0 || !accepted) {
1628
+ deps[dep.childId] = { skipped: !completed ? `the implementer did not complete (${stopped ? "stopped" : record?.outcome ?? "no result"})` : patch.files.length === 0 ? "the implementer changed nothing" : "the implementer's acceptance failed" };
1629
+ } else if (!existsSync(ws)) {
1630
+ deps[dep.childId] = { skipped: "the implementer's tree was already removed" };
1631
+ } else {
1632
+ const depWs = join(manifestDir, dep.childId, "ws");
1633
+ mkdirSync(dirname(depWs), { recursive: true });
1634
+ if (i === (man.dependents ?? []).length - 1 && existsSync(ws)) renameSync(ws, depWs);
1635
+ else cpSync(ws, depWs, { recursive: true, verbatimSymlinks: true });
1636
+ const depMan = JSON.parse(readFileSync(join(manifestDir, `${dep.childId}.json`), "utf8"));
1637
+ const depCwd = join(depWs, man.sub ?? "");
1638
+ writeDurable(join(manifestDir, `${dep.childId}.policy`, "policy.mjs"), rolePolicyContent("verifier", depMan.scope !== undefined ? { root: depCwd, globs: depMan.scope } : undefined));
1639
+ writeFileSync(join(manifestDir, `${dep.childId}.json`), `${JSON.stringify({ ...depMan, workspace: depWs, base: man.base, sub: man.sub })}\n`);
1640
+ const started = await manager.start({
1641
+ command: `verifier: ${String(depMan.task).split("\n")[0].slice(0, 80)}`,
1642
+ cwd: depCwd,
1643
+ env: childEnv(join(manifestDir, `${dep.childId}.policy`), sessionsDir, depMan.reasoning),
1644
+ ...(info.executionId !== undefined ? { executionId: info.executionId } : {}),
1645
+ agent: { role: "verifier", session: dep.childId, collect: true },
1646
+ exec: (dir) => ({ file: process.execPath, args: [...childArgs(process.env.KISO_SUBAGENT_BIN ?? process.argv[1], dep.childId, join(manifestDir, `${dep.childId}.task`), depMan.model), ...(depMan.maxTurns !== undefined ? ["--max-turns", String(depMan.maxTurns)] : []), "--result-file", join(dir, "result.md")] }),
1647
+ });
1648
+ deps[dep.childId] = { taskId: started.id };
1649
+ }
1650
+ writeAtomic(depsPath, `${JSON.stringify(deps)}\n`);
1651
+ }
1652
+ // 4. the workspace goes (unless it was handed over), 5. `collected`, last
1653
+ rmSync(ws, { recursive: true, force: true });
1654
+ return done(stopped ? "stopped" : patch.files.length === 0 ? "unchanged" : "collected");
1655
+ }
1656
+
1657
+ /** B — the patch against the snapshot (binary-safe, renames as a delete
1658
+ * plus a create), each changed file's base and child versions, and the
1659
+ * index that names them: patch.json, written last of the three. */
1660
+ function writePatch(ws, base, taskDir, stopped, acceptance) {
1661
+ execFileSync("git", ["-C", ws, "add", "-A"], { stdio: "ignore" });
1662
+ const diff = execFileSync("git", ["-C", ws, "diff", "--cached", "--binary", "--no-renames", base], { maxBuffer: 1024 * 1024 * 1024 });
1663
+ writeAtomic(join(taskDir, "patch.diff"), diff);
1664
+ const raw = execFileSync("git", ["-C", ws, "diff", "--cached", "--no-renames", "--raw", "-z", base], { maxBuffer: 256 * 1024 * 1024 }).toString("utf8").split("\0");
1665
+ const files = [];
1666
+ const vdir = join(taskDir, "versions");
1667
+ mkdirSync(vdir, { recursive: true });
1668
+ for (let i = 0; i + 1 < raw.length; i += 2) {
1669
+ const [srcMode, dstMode, , , status] = raw[i].replace(/^:/, "").split(" ");
1670
+ const path = raw[i + 1];
1671
+ const n = files.length;
1672
+ const entry = { path, status, baseMode: srcMode, mode: dstMode };
1673
+ if (status !== "A") {
1674
+ writeFileSync(join(vdir, `${n}.base`), execFileSync("git", ["-C", ws, "show", `${base}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1675
+ }
1676
+ if (status !== "D") {
1677
+ writeFileSync(join(vdir, `${n}.child`), execFileSync("git", ["-C", ws, "show", `:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1678
+ }
1679
+ files.push(entry);
1680
+ }
1681
+ const bytes = statSync(join(taskDir, "patch.diff")).size;
1682
+ // the acceptance by its name: apply-patch re-resolves it from the configuration
1683
+ writeAtomic(join(taskDir, "patch.json"), `${JSON.stringify({ base, bytes, files, completeness: stopped ? "partial" : "complete", applyable: !stopped, ...(acceptance !== undefined ? { acceptance } : {}) })}\n`);
1684
+ }
1685
+
1686
+ function writeAtomic(path, content) {
1687
+ const tmp = `${path}.tmp`;
1688
+ writeFileSync(tmp, content);
1689
+ renameSync(tmp, path);
1690
+ }
1691
+
1692
+ /** 0.49.0 C1 — the conversation's live binding, as the host reports it;
1693
+ * null when the host gives none (an SDK caller, a test) or fails. */
1694
+ function bindingOf(host, sessionId) {
1695
+ if (typeof host.currentBinding !== "function") return null;
1696
+ try {
1697
+ const b = host.currentBinding(sessionId);
1698
+ return b !== null && typeof b === "object" ? b : null;
1699
+ } catch {
1700
+ return null;
1701
+ }
1702
+ }
1703
+
1704
+ /** 0.49.0 C1 — which profile a child runs on, and at what effort. The first
1705
+ * that applies wins: the task's own `model`; the user's subagents.model;
1706
+ * the conversation's profile, with the conversation's effort; else none —
1707
+ * the child inherits the environment, and its handoff says so. Resolved at
1708
+ * dispatch, so a /model switch between two delegations is honoured. */
1709
+ export function resolveChild(task, cfg, binding) {
1710
+ if (task.model !== undefined) return { profile: task.model, source: "task" };
1711
+ if (cfg.subagentsModel !== undefined) return { profile: cfg.subagentsModel, source: "subagents.model" };
1712
+ if (binding !== null && typeof binding.profile === "string" && binding.profile !== "") {
1713
+ return { profile: binding.profile, ...(binding.reasoning !== undefined && binding.reasoning !== null ? { reasoning: binding.reasoning } : {}), source: "conversation" };
1714
+ }
1715
+ return { source: "environment" };
1716
+ }
1717
+
1718
+ /** The resolution as a manifest records it — a restart replays it. */
1719
+ function modelFieldsOf(resolved) {
1720
+ return { modelSource: resolved.source, ...(resolved.profile !== undefined ? { model: resolved.profile } : {}), ...(resolved.reasoning !== undefined ? { reasoning: resolved.reasoning } : {}) };
1721
+ }
1722
+
1723
+ /** I5: a child's result — its record (result.json) commits its answer
1724
+ * (result.md); without a valid record there is no answer to read. */
1725
+ function readChildResult(childDir) {
1726
+ let raw = null;
1727
+ try {
1728
+ raw = readFileSync(join(childDir, "result.json"), "utf8");
1729
+ } catch {
1730
+ raw = null;
1731
+ }
1732
+ const record = handoffRecordOf(raw);
1733
+ if (record === null) return { record: null, full: null, answer: "" };
1734
+ let answer = "";
1735
+ try {
1736
+ answer = readFileSync(join(childDir, "result.md"), "utf8");
1737
+ } catch {
1738
+ answer = "";
1739
+ }
1740
+ return { record, full: JSON.parse(raw), answer };
1741
+ }
1742
+
1743
+ /** The last `bytes` of a file as read, and whether it held more — the
1744
+ * runtime delivery's reader, the same way. */
1745
+ function rawTail(path, bytes) {
1746
+ try {
1747
+ const fd = openSync(path, "r");
1748
+ try {
1749
+ const size = statSync(path).size;
1750
+ const n = Math.min(size, bytes);
1751
+ const buf = Buffer.alloc(n);
1752
+ readSync(fd, buf, 0, n, size - n);
1753
+ return { text: buf.toString("utf8"), cut: size > n };
1754
+ } finally {
1755
+ closeSync(fd);
1756
+ }
1757
+ } catch {
1758
+ return { text: "", cut: false };
1759
+ }
1760
+ }
1761
+
1762
+ // ── The handoff format (0.49.0 C3/I7) ───────────────────────────────────
1763
+ // The runtime's tasks/handoff.ts, byte for byte: this extension has no
1764
+ // runtime dependency, so it carries its own copy, and the corpus in
1765
+ // tests/fixtures/handoff holds both to the same expected bytes.
1766
+
1767
+ export const CHILD_HANDOFF_BYTES = 4_096;
1768
+ export const GROUP_HANDOFF_BYTES = 16_384;
1769
+ export const HANDOFF_TAIL_BYTES = 2_048;
1770
+ export const HANDOFF_ERROR_BYTES = 1_024;
1771
+
1772
+ /** The record a result.json holds, or null — the commit test (I5). */
1773
+ export function handoffRecordOf(raw) {
1774
+ if (raw === null) return null;
1775
+ try {
1776
+ const parsed = JSON.parse(raw);
1777
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.outcome !== "string" || parsed.outcome === "") return null;
1778
+ return typeof parsed.error === "string" && parsed.error !== "" ? { outcome: parsed.outcome, error: parsed.error } : { outcome: parsed.outcome };
1779
+ } catch {
1780
+ return null;
1781
+ }
1782
+ }
1783
+
1784
+ /** One child's handoff body: lines each led by "\n", and the bytes shown. */
1785
+ export function handoffBody(input, budget) {
1786
+ let text = "";
1787
+ let used = 0;
1788
+ const room = () => Math.max(0, budget - used);
1789
+ if (input.record?.error !== undefined) {
1790
+ const line = headOf(`error: ${input.record.error.replace(/\s*\n\s*/g, " ").trim()}`, Math.min(HANDOFF_ERROR_BYTES, room()));
1791
+ if (line.text !== "") {
1792
+ text += `\n${line.text}${line.cut ? "…" : ""}`;
1793
+ used += Buffer.byteLength(line.text);
1794
+ }
1795
+ }
1796
+ const answer = input.record === null ? "" : input.answer.trimEnd();
1797
+ if (answer !== "") {
1798
+ if (Buffer.byteLength(answer) <= room()) return { text: `${text}\n${answer}`, bytes: used + Buffer.byteLength(answer) };
1799
+ if (room() === 0) return { text: `${text}\n[the whole answer: ${input.resultPath}]`, bytes: used };
1800
+ const cut = headOf(answer, room()).text;
1801
+ return { text: `${text}\n${cut}\n… [truncated; the whole answer: ${input.resultPath}]`, bytes: used + Buffer.byteLength(cut) };
1802
+ }
1803
+ const tail = input.tail.trimEnd();
1804
+ if (tail !== "") {
1805
+ const kept = tailOf(tail, Math.min(HANDOFF_TAIL_BYTES, room()));
1806
+ if (kept.text !== "") {
1807
+ text += `\n${input.tailCut || kept.cut ? "…" : ""}${kept.text}`;
1808
+ used += Buffer.byteLength(kept.text);
1809
+ }
1810
+ }
1811
+ return { text, bytes: used };
1812
+ }
1813
+
1814
+ /** The first `bytes` of `s`, never splitting a character. */
1815
+ function headOf(s, bytes) {
1816
+ const buf = Buffer.from(s, "utf8");
1817
+ if (buf.length <= bytes) return { text: s, cut: false };
1818
+ return { text: buf.subarray(0, Math.max(0, bytes)).toString("utf8").replace(/�+$/, ""), cut: true };
1819
+ }
1820
+
1821
+ /** The last `bytes` of `s`, never splitting a character. */
1822
+ function tailOf(s, bytes) {
1823
+ const buf = Buffer.from(s, "utf8");
1824
+ if (buf.length <= bytes) return { text: s, cut: false };
1825
+ return { text: buf.subarray(buf.length - Math.max(0, bytes)).toString("utf8").replace(/^�+/, ""), cut: true };
1826
+ }
1827
+
1114
1828
  function failSection(childId, role, task, reason) {
1115
1829
  return { failed: true, failKind: "error", text: `[subagent] ${role}: ${task}\n FAILED: ${reason}`, toolCalls: 0 };
1116
1830
  }