@henryqw/pi-subagent 26.5.1 → 27.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js CHANGED
@@ -7,8 +7,11 @@ import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
7
7
  const TRUNCATION_MARKER = "\n[truncated]";
8
8
  const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
9
9
  const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
10
+ const CORRECTION_DELIVERY_RESERVE_BYTES = 1024;
10
11
  const MAX_WORKER_PROMPTS = 32;
11
12
  const MAX_QUEUED_FOLLOWUPS = 16;
13
+ /** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
14
+ export const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
12
15
  export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
13
16
  export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
14
17
  export const STATUS_INSPECTION_BUDGET_MS = 5_000;
@@ -369,6 +372,9 @@ function exactCommandResults(results, checks) {
369
372
  && result.args.every((arg, argIndex) => arg === check.args[argIndex]);
370
373
  });
371
374
  }
375
+ function isCommitNudge(prompt) {
376
+ return prompt.kind === "followup" && prompt.instruction === COMMIT_NUDGE_INSTRUCTION;
377
+ }
372
378
  function correctionEligible(request, attempt) {
373
379
  const initial = attempt.prompts[0];
374
380
  if (initial?.kind !== "initial" || attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
@@ -377,11 +383,13 @@ function correctionEligible(request, attempt) {
377
383
  const latestPrompt = attempt.prompts.at(-1);
378
384
  if (latestPrompt.status !== "settled")
379
385
  return false;
386
+ // A commit nudge continues its turn; it never consumes the correction window.
387
+ const assignments = attempt.prompts.filter((prompt) => !isCommitNudge(prompt)).length;
380
388
  if (!attempt.candidate) {
381
- return attempt.prompts.length === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
389
+ return assignments === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
382
390
  }
383
391
  const checks = attempt.preliminaryChecks;
384
- return attempt.prompts.length === 1 && Boolean(checks
392
+ return assignments === 1 && Boolean(checks
385
393
  && checks.phase === "preliminary"
386
394
  && exactCommandResults(checks.results, request.checks)
387
395
  && sameIdentity(checks.candidate, attempt.candidate)
@@ -403,10 +411,10 @@ export function buildChangesetTaskPrompt(input) {
403
411
  && (typeof input.failure !== "string" || !input.failure.trim() || input.failure.trim() !== input.failure || input.failure.includes("\0"))) {
404
412
  throw new Error("correction failure must be a non-empty exact string.");
405
413
  }
406
- if (input.kind === "followup"
414
+ if ((input.kind === "followup" || input.instruction !== undefined)
407
415
  && (typeof input.instruction !== "string" || !input.instruction.trim() || input.instruction.trim() !== input.instruction
408
416
  || input.instruction.includes("\0") || input.instruction.length > 32_000)) {
409
- throw new Error("follow-up instruction must be non-empty exact text of at most 32000 characters.");
417
+ throw new Error(`${input.kind === "followup" ? "follow-up" : "correction"} instruction must be non-empty exact text of at most 32000 characters.`);
410
418
  }
411
419
  const checks = input.task.checks.map((check) => JSON.stringify({ command: check.command, args: check.args })).join("\n");
412
420
  const upstreamTaskData = input.contexts.length
@@ -440,6 +448,7 @@ export function buildChangesetTaskPrompt(input) {
440
448
  "Required checks (direct command/argv):",
441
449
  checks,
442
450
  ...(input.kind === "correction" ? ["", "Correction failure:", input.failure] : []),
451
+ ...(input.kind === "correction" && input.instruction ? ["", "Main's instruction:", input.instruction] : []),
443
452
  "",
444
453
  "Work only in the exact worktree above. Commit the complete result and leave that worktree clean.",
445
454
  ].join("\n");
@@ -572,6 +581,55 @@ export class IsolatedRunner {
572
581
  }
573
582
  return { requests, invalidIds: inventory.invalidIds, leaseBusy };
574
583
  }
584
+ /** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
585
+ * mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
586
+ async pruneOwnership(checkouts) {
587
+ const owners = [];
588
+ let lease;
589
+ for (const checkout of checkouts) {
590
+ const root = realpathSync.native(checkout);
591
+ if (!lease && await this.store.hasProductiveRunLease(root))
592
+ lease = `productive lease is held by a live run in ${root}`;
593
+ const { states, invalidIds } = await this.store.list(root);
594
+ for (const { state } of states) {
595
+ if (!terminal(state))
596
+ owners.push({ label: `owned by ${state.status} request ${state.request.id}`, text: JSON.stringify(state) });
597
+ }
598
+ for (const id of invalidIds) {
599
+ let text;
600
+ try {
601
+ text = await this.store.readRaw(root, id);
602
+ }
603
+ catch { /* It may own any name. */ }
604
+ owners.push({ label: `unreadable state ${id} in ${root} may own it`, text });
605
+ }
606
+ }
607
+ return { ...(lease ? { lease } : {}), owner: (name) => owners.find(({ text }) => text === undefined || text.includes(name))?.label };
608
+ }
609
+ /** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
610
+ async reconcilePrunedAborts(checkouts, names) {
611
+ const lines = [];
612
+ for (const checkout of checkouts) {
613
+ const root = realpathSync.native(checkout);
614
+ const { requests, states } = await this.listRequests(root);
615
+ for (const [index, state] of states.entries()) {
616
+ const id = state.request.id;
617
+ const text = JSON.stringify(state);
618
+ if (requests[index].status !== "aborted · retained" || !names.some((name) => text.includes(name)))
619
+ continue;
620
+ try {
621
+ await this.abort(id, root);
622
+ const after = (await this.listRequests(root)).requests.find((request) => request.id === id);
623
+ lines.push(after?.status === "aborted" ? `Reconciled cleanup of aborted request ${id}.`
624
+ : `Aborted request ${id} still has pending cleanup; inspect subagent_status.`);
625
+ }
626
+ catch (error) {
627
+ lines.push(`Aborted request ${id} could not be reconciled: ${errorText(error)}`);
628
+ }
629
+ }
630
+ }
631
+ return lines;
632
+ }
575
633
  canFollowup(root, requestId, taskId) {
576
634
  const active = this.activeControl(realpathSync.native(root), requestId, taskId);
577
635
  return active !== undefined && active.task.status === "working" && !active.control.isSealed;
@@ -757,6 +815,8 @@ export class IsolatedRunner {
757
815
  throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
758
816
  if (request.action === "verify")
759
817
  throw new Error(`Text task ${task.taskId} cannot be verified.`);
818
+ if (request.instruction !== undefined)
819
+ throw new Error(`Text task ${task.taskId} retry cannot take a worker instruction.`);
760
820
  if (!textRetryEligible(state, task)) {
761
821
  const attempt = task.attempts.at(-1);
762
822
  if ((attempt && !["failed", "superseded"].includes(attempt.status)) || task.attempts.length >= 2) {
@@ -771,7 +831,7 @@ export class IsolatedRunner {
771
831
  if (task.status !== "needs_attention")
772
832
  throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
773
833
  if (request.action === "retry")
774
- return await this.retry(handle, task, scope);
834
+ return await this.retry(handle, task, scope, request.instruction);
775
835
  return await this.verifyRetainedTask(handle, task, scope);
776
836
  }
777
837
  catch (error) {
@@ -2179,6 +2239,8 @@ export class IsolatedRunner {
2179
2239
  let kind = initialKind;
2180
2240
  let failure = task.failure;
2181
2241
  let instruction = initialInstruction;
2242
+ // A nudge keeps its dirty turn's exact clean pre-prompt lineage.
2243
+ let nudgeFrom;
2182
2244
  for (;;) {
2183
2245
  if (kind === "followup" && instruction === undefined) {
2184
2246
  throw new Error("Follow-up worker execution requires an exact queued instruction.");
@@ -2195,11 +2257,12 @@ export class IsolatedRunner {
2195
2257
  }
2196
2258
  let preCandidate;
2197
2259
  try {
2198
- preCandidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
2260
+ preCandidate = nudgeFrom ?? runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
2199
2261
  root: state.root,
2200
2262
  task: request,
2201
2263
  attempt,
2202
2264
  }, context)), "Pre-prompt candidate identity");
2265
+ nudgeFrom = undefined;
2203
2266
  }
2204
2267
  catch (error) {
2205
2268
  this.rethrowStopped(error);
@@ -2233,7 +2296,7 @@ export class IsolatedRunner {
2233
2296
  kind,
2234
2297
  status: "submitting",
2235
2298
  preCandidate,
2236
- ...(kind === "followup" ? { instruction } : {}),
2299
+ ...(instruction !== undefined ? { instruction } : {}),
2237
2300
  at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
2238
2301
  };
2239
2302
  attempt.prompts.push(prompt);
@@ -2278,6 +2341,19 @@ export class IsolatedRunner {
2278
2341
  return;
2279
2342
  }
2280
2343
  prompt.status = "settled";
2344
+ if (worker.outcome === "uncommitted") {
2345
+ prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, "uncommitted worker diagnostic");
2346
+ if (attempt.prompts.some(isCommitNudge)) {
2347
+ this.attention(task, "The commit nudge was sent, but the worker turn still left uncommitted changes in its worktree; they are retained for inspection.");
2348
+ await this.saveProductive(handle);
2349
+ return;
2350
+ }
2351
+ await this.saveProductive(handle);
2352
+ kind = "followup";
2353
+ instruction = COMMIT_NUDGE_INSTRUCTION;
2354
+ nudgeFrom = preCandidate;
2355
+ continue;
2356
+ }
2281
2357
  if (worker.outcome === "blocked") {
2282
2358
  failure = boundedRuntimeDiagnostic(worker.diagnostic, "blocked worker diagnostic");
2283
2359
  prompt.failure = failure;
@@ -2522,8 +2598,11 @@ export class IsolatedRunner {
2522
2598
  await this.saveProductive(handle);
2523
2599
  return this.response(handle.state);
2524
2600
  }
2525
- async retry(handle, task, scope) {
2601
+ async retry(handle, task, scope, instruction) {
2526
2602
  const attempt = task.attempts.at(-1);
2603
+ if (instruction !== undefined && !attempt?.prompts.length) {
2604
+ throw new Error("A retry instruction requires a same-worker correction of a prompted attempt.");
2605
+ }
2527
2606
  if (!attempt) {
2528
2607
  for (const pending of handle.state.tasks) {
2529
2608
  if (pending.status === "needs_attention" && pending.attempts.length === 0) {
@@ -2538,11 +2617,26 @@ export class IsolatedRunner {
2538
2617
  if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
2539
2618
  throw new Error("An ambiguous delivered prompt is never replayed.");
2540
2619
  }
2541
- if (!this.correctionAllowed(handle.state, changesetTaskRequest(handle.state, task.taskId), attempt)) {
2620
+ const request = changesetTaskRequest(handle.state, task.taskId);
2621
+ if (!this.correctionAllowed(handle.state, request, attempt)) {
2542
2622
  throw new Error("The same-agent correction is unavailable or already used.");
2543
2623
  }
2624
+ // Build the correction before it is recorded: an oversized prompt must not use up the only correction.
2625
+ // The reserve covers the worktree path and turn identity the host adds before delivery.
2626
+ const assignment = buildChangesetTaskPrompt({
2627
+ goal: handle.state.request.goal,
2628
+ contexts: resolveTextTaskContexts(handle.state, request),
2629
+ task: request,
2630
+ kind: "correction",
2631
+ worktreeCwd: allocationByKind(attempt, "agent")?.worktreeCwd ?? "",
2632
+ failure: task.failure,
2633
+ ...(instruction === undefined ? {} : { instruction }),
2634
+ });
2635
+ if (Buffer.byteLength(assignment, "utf8") + CORRECTION_DELIVERY_RESERVE_BYTES > CHANGESET_TASK_PROMPT_MAX_BYTES) {
2636
+ throw new Error(`Worker assignment exceeds ${CHANGESET_TASK_PROMPT_MAX_BYTES} bytes with delivery overhead; shorten the retry instruction.`);
2637
+ }
2544
2638
  task.status = "working";
2545
- await this.driveWorkerSafely(handle, task, scope, "correction");
2639
+ await this.driveWorkerSafely(handle, task, scope, "correction", instruction);
2546
2640
  }
2547
2641
  else {
2548
2642
  if (attempt.termination || allocationByKind(attempt, "agent")) {
package/dist/schema.d.ts CHANGED
@@ -121,6 +121,7 @@ export declare const ResumeRequestSchema: Type.TUnion<[Type.TObject<{
121
121
  id: Type.TString;
122
122
  action: Type.TLiteral<"retry">;
123
123
  taskId: Type.TString;
124
+ instruction: Type.TOptional<Type.TString>;
124
125
  }>, Type.TObject<{
125
126
  id: Type.TString;
126
127
  action: Type.TLiteral<"verify">;
@@ -133,6 +134,7 @@ export declare const ResumeRequestParameters: Type.TObject<{
133
134
  id: Type.TString;
134
135
  action: Type.TUnion<[Type.TLiteral<"retry">, Type.TLiteral<"verify">, Type.TLiteral<"finalize">]>;
135
136
  taskId: Type.TOptional<Type.TString>;
137
+ instruction: Type.TOptional<Type.TString>;
136
138
  }>;
137
139
  export type IdOnly = Static<typeof IdOnlySchema>;
138
140
  export type CheckCommand = Static<typeof CheckCommandSchema>;
@@ -2389,6 +2391,10 @@ export declare const StatusOutputSchema: Type.TObject<{
2389
2391
  scope: Type.TUnion<[Type.TLiteral<"task">, Type.TLiteral<"final">, Type.TLiteral<"request">]>;
2390
2392
  taskId: Type.TOptional<Type.TString>;
2391
2393
  failure: Type.TOptional<Type.TString>;
2394
+ failedReview: Type.TOptional<Type.TObject<{
2395
+ verdict: Type.TString;
2396
+ verdictTruncated: Type.TBoolean;
2397
+ }>>;
2392
2398
  }>>;
2393
2399
  createdAt: Type.TInteger;
2394
2400
  updatedAt: Type.TInteger;
@@ -2421,6 +2427,7 @@ export declare const StatusOutputSchema: Type.TObject<{
2421
2427
  id: Type.TString;
2422
2428
  action: Type.TLiteral<"retry">;
2423
2429
  taskId: Type.TString;
2430
+ instruction: Type.TOptional<Type.TString>;
2424
2431
  }>, Type.TObject<{
2425
2432
  id: Type.TString;
2426
2433
  action: Type.TLiteral<"verify">;
package/dist/schema.js CHANGED
@@ -62,7 +62,7 @@ export const ExecuteRequestSchema = Type.Object({
62
62
  }, { additionalProperties: false });
63
63
  export const IdOnlySchema = Type.Object({ id: IdSchema }, { additionalProperties: false });
64
64
  export const ResumeRequestSchema = Type.Union([
65
- Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema }, { additionalProperties: false }),
65
+ Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema, instruction: Type.Optional(TextSchema) }, { additionalProperties: false }),
66
66
  Type.Object({ id: IdSchema, action: Type.Literal("verify"), taskId: IdSchema }, { additionalProperties: false }),
67
67
  Type.Object({ id: IdSchema, action: Type.Literal("finalize") }, { additionalProperties: false }),
68
68
  ]);
@@ -75,6 +75,7 @@ export const ResumeRequestParameters = Type.Object({
75
75
  ResumeRequestSchema.anyOf[2].properties.action,
76
76
  ]),
77
77
  taskId: Type.Optional(IdSchema),
78
+ instruction: ResumeRequestSchema.anyOf[0].properties.instruction,
78
79
  }, { additionalProperties: false });
79
80
  const AllocationLifecycleFields = {
80
81
  generation: Type.Integer({ minimum: 1, maximum: 2 }),
@@ -466,6 +467,7 @@ export const StatusOutputSchema = Type.Object({
466
467
  scope: Type.Union([Type.Literal("task"), Type.Literal("final"), Type.Literal("request")]),
467
468
  taskId: Type.Optional(IdSchema),
468
469
  failure: Type.Optional(Type.String()),
470
+ failedReview: Type.Optional(Type.Object({ verdict: Type.String(), verdictTruncated: Type.Boolean() })),
469
471
  })),
470
472
  createdAt: TimestampSchema,
471
473
  updatedAt: TimestampSchema,
@@ -635,7 +637,10 @@ export function parseResumeRequest(value) {
635
637
  if (!Check(ResumeRequestSchema, value)) {
636
638
  throw schemaValidationError("subagent_resume request must match one strict action", Errors(ResumeRequestSchema, value));
637
639
  }
638
- return value;
640
+ const request = value;
641
+ if ("instruction" in request && request.instruction !== undefined)
642
+ requireExactAllocationText(request.instruction, "Retry instruction");
643
+ return request;
639
644
  }
640
645
  export function sameIdentity(left, right) {
641
646
  return left.branch === right.branch && left.head === right.head && left.index === right.index && left.tree === right.tree;
@@ -1080,11 +1085,11 @@ export function parseRunState(value) {
1080
1085
  }
1081
1086
  for (let promptIndex = 0; promptIndex < attempt.prompts.length; promptIndex += 1) {
1082
1087
  const prompt = attempt.prompts[promptIndex];
1083
- if ((prompt.kind === "followup") !== (prompt.instruction !== undefined)) {
1084
- throw new Error(`Follow-up prompt history for ${definition.id} has invalid instruction evidence.`);
1088
+ if (prompt.kind === "followup" ? prompt.instruction === undefined : prompt.kind === "initial" && prompt.instruction !== undefined) {
1089
+ throw new Error(`Prompt history for ${definition.id} has invalid instruction evidence.`);
1085
1090
  }
1086
1091
  if (prompt.instruction !== undefined) {
1087
- requireExactAllocationText(prompt.instruction, `Follow-up prompt instruction for ${definition.id}`);
1092
+ requireExactAllocationText(prompt.instruction, `Prompt instruction for ${definition.id}`);
1088
1093
  }
1089
1094
  if (promptIndex === 0)
1090
1095
  continue;
package/dist/store.d.ts CHANGED
@@ -48,6 +48,8 @@ export declare class FileRunStore {
48
48
  states: RunStateHandle[];
49
49
  invalidIds: string[];
50
50
  }>;
51
+ /** Raw text of a state file, for evidence when it does not parse. */
52
+ readRaw(root: string, id: string): Promise<string>;
51
53
  private handle;
52
54
  }
53
55
  export {};
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
- import { mkdir, open, readdir, realpath } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readdir, realpath } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
6
6
  import { check, lock } from "proper-lockfile";
@@ -272,8 +272,13 @@ export class FileRunStore {
272
272
  const entries = await this.stateEntries(root);
273
273
  const states = [];
274
274
  const invalidIds = [];
275
- for (const entry of entries.filter((item) => item.isFile())) {
275
+ for (const entry of entries) {
276
276
  const id = entry.name.slice(0, -5);
277
+ // A symlink, directory, or other non-regular entry is unreadable state, never absent state.
278
+ if (!entry.isFile()) {
279
+ invalidIds.push(id);
280
+ continue;
281
+ }
277
282
  try {
278
283
  states.push(await this.load(root, id));
279
284
  }
@@ -283,6 +288,13 @@ export class FileRunStore {
283
288
  }
284
289
  return { states, invalidIds };
285
290
  }
291
+ /** Raw text of a state file, for evidence when it does not parse. */
292
+ async readRaw(root, id) {
293
+ const path = this.statePath(root, id);
294
+ if (!(await lstat(path)).isFile())
295
+ throw new Error(`State entry ${id} is not a regular file.`);
296
+ return await readTextFileBounded(path, STATE_MAX_BYTES);
297
+ }
286
298
  handle(state, path) {
287
299
  return new RunStateHandle(state, path, async (contents) => {
288
300
  await this.assertSafeDestination(state.root, path);
@@ -24,6 +24,9 @@ export type WorktreePayload = {
24
24
  commits?: number;
25
25
  dirty?: boolean;
26
26
  };
27
+ export declare const WORKTREES_DIRNAME = ".worktrees";
28
+ /** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
29
+ export declare const OWNED_BRANCH_PATTERN: RegExp;
27
30
  export declare class WorktreeSetupError extends Error {
28
31
  name: string;
29
32
  readonly worktree: WorktreeInfo;
package/dist/worktree.js CHANGED
@@ -3,8 +3,10 @@ import { existsSync, lstatSync } from "node:fs";
3
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { runGit } from "./git-process.js";
6
- const WORKTREES_DIRNAME = ".worktrees";
6
+ export const WORKTREES_DIRNAME = ".worktrees";
7
7
  const BRANCH_NAMESPACE = "pi-subagent";
8
+ /** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
9
+ export const OWNED_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
8
10
  export class WorktreeSetupError extends Error {
9
11
  name = "WorktreeSetupError";
10
12
  worktree;
@@ -0,0 +1,163 @@
1
+ # Migrate from 26.x to 27.0.0
2
+
3
+ Version 27.0.0 changes how direct tasks declare writes, how Role files load, and one library function. Most users must do two things: make sure Main sends `access` on each direct task, and fix Role files that have unknown keys. Use the [checklist](#upgrade-checklist) at the end.
4
+
5
+ ## Direct tasks must declare `access`
6
+
7
+ Each direct task must now have `access: "read"` or `access: "write"`. A request without `access`, or with another value, fails before launch. The error names the task, for example `tasks[1].access must be "read" or "write" (missing)`.
8
+
9
+ Main sets `access`, not you. The packaged `pi-subagent` Skill and the tool schema tell Main to do this. You must change requests only if you wrote them yourself, for example in saved prompts, examples, or scripts.
10
+
11
+ Use `write` when the task can change files, Git state, or external resources. Use `read` for all other tasks. Nothing enforces `read`: there is no sandbox, and a read task keeps all of its Role's tools.
12
+
13
+ Single task, before:
14
+
15
+ ```json
16
+ { "mode": "direct", "role": "scout", "name": "Map sign-in flow", "task": "Trace sign-in. Do not edit files." }
17
+ ```
18
+
19
+ Single task, after:
20
+
21
+ ```json
22
+ { "mode": "direct", "role": "scout", "name": "Map sign-in flow", "task": "Trace sign-in. Do not edit files.", "access": "read" }
23
+ ```
24
+
25
+ Parallel tasks, before:
26
+
27
+ ```json
28
+ {
29
+ "mode": "direct",
30
+ "tasks": [
31
+ { "role": "scout", "name": "Inspect service", "task": "Inspect the service boundary." },
32
+ { "role": "reviewer", "name": "Inspect tests", "task": "Inspect the test contract." }
33
+ ]
34
+ }
35
+ ```
36
+
37
+ Parallel tasks, after:
38
+
39
+ ```json
40
+ {
41
+ "mode": "direct",
42
+ "tasks": [
43
+ { "role": "scout", "name": "Inspect service", "task": "Inspect the service boundary.", "access": "read" },
44
+ { "role": "reviewer", "name": "Inspect tests", "task": "Inspect the test contract.", "access": "read" }
45
+ ]
46
+ }
47
+ ```
48
+
49
+ Chain, before:
50
+
51
+ ```json
52
+ {
53
+ "mode": "direct",
54
+ "chain": [
55
+ { "role": "scout", "name": "Collect evidence", "task": "Collect exact evidence." },
56
+ { "role": "implementer", "name": "Apply fix", "task": "Fix the issue in this report and commit it:\n{previous}" }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ Chain, after:
62
+
63
+ ```json
64
+ {
65
+ "mode": "direct",
66
+ "chain": [
67
+ { "role": "scout", "name": "Collect evidence", "task": "Collect exact evidence.", "access": "read" },
68
+ { "role": "implementer", "name": "Apply fix", "task": "Fix the issue in this report and commit it:\n{previous}", "access": "write" }
69
+ ]
70
+ }
71
+ ```
72
+
73
+ ## Roles no longer classify writes
74
+
75
+ In 26.x, pi-subagent used each Role's tools, extensions, and MCP servers to decide if a request could write. A Role with any extension or MCP server counted as a writer. In 27.0.0, only `access` decides this. The results are:
76
+
77
+ - A request with only `read` tasks does not reserve the checkout, and its tasks return plain text. Its parallel `tasks` run at the same time, up to `maxSubagents`; a `chain` still runs its steps in order. Roles that load a provider extension now run in parallel too.
78
+ - A request with one or more `write` tasks runs its tasks one at a time. It reserves the checkout until all of its workers are proved stopped. Each task must return a JSON completion object, as in 26.x.
79
+ - **Inspect status and recovery** in `/subagent` now works with a `scout` Role that loads extensions or MCP servers. It runs as a read task with the Role's own resources.
80
+
81
+ ## Unknown Role frontmatter keys are rejected
82
+
83
+ Role Markdown files can only use these frontmatter keys: `name`, `description`, `modelClass`, `tools`, `extensions`, `skills`, and `mcps`. In 26.x, other keys had no effect. In 27.0.0, a file with another key is invalid. The error names the file and the key:
84
+
85
+ ```text
86
+ <file>: unknown Role frontmatter key "color"; expected name, description, modelClass, tools, extensions, skills, mcps.
87
+ ```
88
+
89
+ To fix it, open the file and remove the key. Keys copied from other agent tools often cause this error. Examples are `color` and `model`. For a model, use `modelClass` with `fast`, `balanced`, `frontier`, or `fav`. Main can also select a route or a `model` on each task.
90
+
91
+ ## Role errors are reported per file
92
+
93
+ In 26.x, one invalid Role file stopped all Roles from loading. In 27.0.0, each file loads on its own:
94
+
95
+ - Valid Roles load and work.
96
+ - A task that names the Role of an invalid file fails, and the error names that file.
97
+ - Two files with the same `name` make that name unavailable until you remove one.
98
+
99
+ Run `/subagent roles` to see each Role, its source (built-in, user override, or user), its resolved model route, its resources, and all file errors. This command only reads. It does not need a Herdr pane.
100
+
101
+ ## New `roleDefaults` setting
102
+
103
+ `~/.pi/agent/config/pi-subagent/config.json` accepts an optional `roleDefaults` object. Its `extensions`, `tools`, and `skills` arrays merge into every built-in and user Role. The defaults come first in each list, and duplicates are removed. Use it for the provider extension that every child needs:
104
+
105
+ ```json
106
+ {
107
+ "roleDefaults": {
108
+ "extensions": ["npm:@example/pi-provider"]
109
+ }
110
+ }
111
+ ```
112
+
113
+ **Warning:** 26.x rejects the `roleDefaults` key, and an unknown key blocks all delegation. Upgrade to 27.0.0 before you add `roleDefaults` to the file.
114
+
115
+ ## Library API: `loadRoles` is replaced
116
+
117
+ This section applies only if your code imports `@henryqw/pi-subagent`. `loadRoles` is removed. Use `loadRoleCatalog` to load all Roles and `findRole` to select one. The catalog also contains each Role's source and the per-file errors, and it applies `roleDefaults`.
118
+
119
+ Before:
120
+
121
+ ```ts
122
+ import { loadRoles } from "@henryqw/pi-subagent";
123
+
124
+ const role = loadRoles().find((candidate) => candidate.name === "scout");
125
+ if (!role) throw new Error("Unknown Role: scout");
126
+ ```
127
+
128
+ After:
129
+
130
+ ```ts
131
+ import { findRole, loadRoleCatalog } from "@henryqw/pi-subagent";
132
+
133
+ const catalog = loadRoleCatalog();
134
+ const role = findRole(catalog, "scout"); // Throws with the file error if the Role is invalid.
135
+ for (const error of catalog.errors) console.warn(error.message);
136
+ ```
137
+
138
+ ## Worker output header and partial output
139
+
140
+ Each worker result that Main receives now starts with a header. The header tells Main that the text is the worker's output and has no user authority. Text that looks like harness framing gets a backslash, for example `<system-reminder>` tags or lines that start with `Human:` or `Assistant:`. No other text changes. Isolated status previews use the same header.
141
+
142
+ When a task fails after its worker wrote text, the result keeps a short excerpt marked as partial output. Partial output is never a success.
143
+
144
+ If you parse worker results, expect the header line before the worker text.
145
+
146
+ ## New features you can use
147
+
148
+ These features do not need changes to your setup.
149
+
150
+ - **`continueFrom`**: Main can continue a finished direct worker, for example `"continueFrom": "D7.1"`, with the same Role. The worker resumes its saved Pi session in a new Herdr tab, so it keeps its earlier context. Main uses it for follow-up questions or corrections on the same work. After a continuation, continue the newest reference: an older reference to the same session is refused. For unrelated work, Main starts a fresh task.
151
+ - **Stop this worker**: in `/subagent`, a running workflow with only `read` tasks shows **Stop this worker** for each running worker. It stops only that worker. The other workers continue, and Main receives one result when the workflow finishes. Workflows with a `write` task keep only **Close/cancel-and-release ALL owned workers in this workflow**.
152
+
153
+ ## Upgrade checklist
154
+
155
+ 1. Make a copy of the Role files (`*.md`) and `config.json` in `~/.pi/agent/config/pi-subagent/`.
156
+ 2. Remove unknown frontmatter keys, such as `color` or `model`, from each Role file in `~/.pi/agent/config/pi-subagent/`.
157
+ 3. Upgrade with `pi install npm:@henryqw/pi-subagent`, then restart Pi.
158
+ 4. Run `/subagent roles`. Fix each file error that it shows.
159
+ 5. Optional: add `roleDefaults` to `config.json` for resources that every Role needs. Do this only after the upgrade.
160
+ 6. Add `access` to each direct task in saved prompts, scripts, or examples that you wrote.
161
+ 7. If your code imports the library, replace `loadRoles` with `loadRoleCatalog` and `findRole`.
162
+ 8. If you parse worker results, expect the worker output header.
163
+ 9. Do a small `read` delegation to make sure that the setup works.