@henryqw/pi-subagent 27.5.1 → 27.5.2
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/CONTEXT.md +6 -0
- package/README.md +4 -4
- package/dist/discard.d.ts +1 -1
- package/dist/discard.js +10 -16
- package/dist/prune.d.ts +2 -0
- package/dist/prune.js +2 -2
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +68 -4
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +1 -0
- package/dist/store.d.ts +1 -0
- package/dist/store.js +12 -3
- package/docs/orchestration.md +2 -2
- package/examples/roles/scout.md +2 -1
- package/extensions/admission.ts +13 -1
- package/extensions/isolated.ts +5 -3
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +3 -3
- package/docs/adr/001-composable-ephemeral-execution.md +0 -28
- package/docs/migration.md +0 -163
package/CONTEXT.md
CHANGED
|
@@ -56,6 +56,12 @@ The guarded transfer of a validated integration generation into unchanged, clean
|
|
|
56
56
|
**Release**:
|
|
57
57
|
Main's explicit cleanup of an exact rejected worker or superseded generation after proving that it is no longer selected. Release does not discard dirty, conflicted, or uncertain work.
|
|
58
58
|
|
|
59
|
+
**Discard**:
|
|
60
|
+
The user-confirmed deletion of owned isolated resources from `/subagent`, including committed, uncommitted, and ignored content. Unlike Release, only the user can trigger it.
|
|
61
|
+
|
|
62
|
+
**Orphaned request**:
|
|
63
|
+
An isolated request whose parent checkout was removed. Other checkouts of the same repository can still list and discard it.
|
|
64
|
+
|
|
59
65
|
**Productive lease**:
|
|
60
66
|
Repository-scoped ownership that excludes concurrent execution or resumption while permitting status inspection and abort.
|
|
61
67
|
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Commands and UI are for you. Tools and the packaged skill are for Main, except `
|
|
|
32
32
|
| `/subagent` | command | Browse direct and isolated work, including requests whose parent checkout was removed. Stop direct workers, inspect isolated work, send follow-ups, or delete owned isolated worktrees after confirmation. Without interactive UI, only reports. |
|
|
33
33
|
| `/subagent roles` | command | List Role sources, resources, resolved model and thinking level, route errors, and file errors. Read-only; requires no Herdr pane. |
|
|
34
34
|
| `/subagent prune` | command | Review remaining worker and integration branches and worktrees. Remove merged resources after confirmation. Requires a running Herdr server; without interactive UI, only reports. |
|
|
35
|
-
| `/subagent recover` | command | Classify
|
|
35
|
+
| `/subagent recover` | command | Classify interrupted isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
|
|
36
36
|
| `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. |
|
|
37
37
|
| `subagent_status` | tool | Read saved isolated state, exact identities, failed review verdicts, and allowed continuation. Does not replay work. Supports structured output for `codemode`. |
|
|
38
38
|
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation, not staging or integration. Same-worker correction retries accept optional `instruction` text. |
|
|
@@ -131,7 +131,7 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
|
|
|
131
131
|
|
|
132
132
|
Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. **Refresh** reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
|
|
133
133
|
|
|
134
|
-
Requests whose parent checkout was removed show as **Isolated · orphaned** from any checkout of the same repository.
|
|
134
|
+
Requests whose parent checkout was removed show as **Isolated · orphaned** from any checkout of the same repository. Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
|
|
135
135
|
|
|
136
136
|
Only shown resources can be removed; live jobs, uncertain ownership, or other Herdr panes can block removal. Agents and tools cannot trigger this action. See [Close and delete isolated worktrees](./docs/orchestration.md#close-and-delete-isolated-worktrees) for guards and partial cleanup.
|
|
137
137
|
|
|
@@ -182,7 +182,7 @@ Role Markdown files in `~/.pi/agent/config/pi-subagent/` require frontmatter and
|
|
|
182
182
|
|
|
183
183
|
The package ships `implementer`, `reviewer`, and `scout`; same-name user Roles override built-ins. Roles define capabilities, not access or isolation. Unknown frontmatter keys invalidate that file and report its path and key. Other valid Roles load; duplicate names become unavailable. Delegation to an invalid file's claimed Role fails with that file's error. Inspect `/subagent roles`.
|
|
184
184
|
|
|
185
|
-
The built-in reviewer
|
|
185
|
+
The built-in reviewer and scout include `git_read`; existing user reviewers and scouts must add it to `tools`. No additional extension or Bash access is necessary. See [Reviewer Git inspection](./docs/orchestration.md#reviewer-git-inspection) for operations, parameters, limits, and Git requirements.
|
|
186
186
|
|
|
187
187
|
Children load only declared resources and required adapters. Roles must list their model provider extension. `codemode` requires both `builtin:codemode` in `extensions` and `codemode` in `tools`. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work. Main-only delegation/recovery tools and `ask_question` stay unavailable to children.
|
|
188
188
|
|
|
@@ -226,4 +226,4 @@ After coordinator interruption, restart Pi and run `/subagent recover` in the ca
|
|
|
226
226
|
|
|
227
227
|
Run `/subagent prune` for merged leftovers. Removing a checkout deletes its ignored files only after a separate confirmation naming those entries. Dirty, conflicted, changed, or uncertain resources require deliberate inspection. See [Prune leftover worktrees](./docs/orchestration.md#prune-leftover-worktrees) before removal.
|
|
228
228
|
|
|
229
|
-
For aborted requests with retained resources, inspect status, resolve blockers, and repeat `subagent_abort` to reconcile cleanup without replaying work. Release rejected candidates and superseded generations explicitly. The extension never pushes, opens pull requests, publishes, deploys, force-
|
|
229
|
+
For aborted requests with retained resources, inspect status, resolve blockers, and repeat `subagent_abort` to reconcile cleanup without replaying work. Release rejected candidates and superseded generations explicitly. The extension never pushes, opens pull requests, publishes, deploys, or silently falls back to Main. It force-deletes recoverable work only after the user confirms **Close and delete worktrees (discard work)** in `/subagent`.
|
package/dist/discard.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type GitRunner } from "./git-process.ts";
|
|
|
2
2
|
import type { ChangesetTaskAttempt, ChangesetTaskState, IntegrationGeneration, RunState, TextTaskAttempt } from "./schema.ts";
|
|
3
3
|
import type { RunStateHandle } from "./store.ts";
|
|
4
4
|
import { type WorktreeInfo } from "./worktree.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type PruneGuards } from "./prune.ts";
|
|
6
6
|
export interface DiscardItem {
|
|
7
7
|
key: string;
|
|
8
8
|
identity: string;
|
package/dist/discard.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { runGit } from "./git-process.js";
|
|
4
4
|
import { inspectIndexFlags, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
5
|
+
import { requireGit, within } from "./prune.js";
|
|
5
6
|
export function discardResources(state) {
|
|
6
7
|
return [
|
|
7
8
|
...state.tasks.flatMap((task) => task.kind === "changeset" ? task.attempts.map((attempt, index) => ({
|
|
@@ -15,19 +16,12 @@ export function discardResources(state) {
|
|
|
15
16
|
...state.integration.generations.map((generation) => ({ key: `integration/${generation.number}`, generation, worktree: generation.worktree })),
|
|
16
17
|
];
|
|
17
18
|
}
|
|
18
|
-
async function git(args, root, run) {
|
|
19
|
-
const result = await run(args, root);
|
|
20
|
-
if (result.code !== 0)
|
|
21
|
-
throw new Error(`git ${args[0]} failed: ${result.stderr.trim().slice(0, 300)}`);
|
|
22
|
-
return result.stdout.trimEnd();
|
|
23
|
-
}
|
|
24
19
|
export async function primaryCheckout(root, run = runGit) {
|
|
25
|
-
const first = (await
|
|
20
|
+
const first = (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], root)).split("\0")[0];
|
|
26
21
|
if (!first?.startsWith("worktree "))
|
|
27
22
|
throw new Error("Primary Git checkout is unavailable.");
|
|
28
23
|
return realpathSync.native(first.slice(9));
|
|
29
24
|
}
|
|
30
|
-
const within = (root, path) => path === root || path.startsWith(`${root}${sep}`);
|
|
31
25
|
const identity = (resource) => JSON.stringify([resource.worktree, resource.attempt?.allocations]);
|
|
32
26
|
/** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
|
|
33
27
|
export async function inspectDiscardResource(primary, mainCwd, resource, guards, run = runGit) {
|
|
@@ -53,14 +47,14 @@ export async function inspectDiscardResource(primary, mainCwd, resource, guards,
|
|
|
53
47
|
return item;
|
|
54
48
|
}
|
|
55
49
|
const ref = `refs/heads/${worktree.branch}`;
|
|
56
|
-
const branches = (await
|
|
50
|
+
const branches = (await requireGit(run, ["for-each-ref", "--format=%(refname) %(objectname) %(symref)", ref], primary)).split("\n");
|
|
57
51
|
const branch = branches.find((line) => line.startsWith(`${ref} `))?.split(" ");
|
|
58
52
|
item.tip = branch?.[1];
|
|
59
53
|
if (branch?.[2])
|
|
60
54
|
item.blockers.push("Owned branch is a symbolic reference; inspect it manually.");
|
|
61
55
|
if (item.tip && worktree.baseCommit)
|
|
62
|
-
item.commits = Number(await
|
|
63
|
-
const records = (await
|
|
56
|
+
item.commits = Number(await requireGit(run, ["rev-list", "--count", `${worktree.baseCommit}..${item.tip}`], primary));
|
|
57
|
+
const records = (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], primary)).split("\0\0");
|
|
64
58
|
const registered = records.filter((record) => record.split("\0").includes(`branch refs/heads/${worktree.branch}`));
|
|
65
59
|
const owned = records.find((record) => record.startsWith(`worktree ${worktree.path}\0`));
|
|
66
60
|
if (registered.some((record) => !record.startsWith(`worktree ${worktree.path}\0`)))
|
|
@@ -87,7 +81,7 @@ export async function inspectDiscardResource(primary, mainCwd, resource, guards,
|
|
|
87
81
|
item.blockers.push("Owned checkout path is redirected.");
|
|
88
82
|
if (within(real, realpathSync.native(mainCwd)))
|
|
89
83
|
item.blockers.push("Main runs inside the checkout.");
|
|
90
|
-
const status = (await
|
|
84
|
+
const status = (await requireGit(run, ["--no-optional-locks", "status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal", "--ignore-submodules=none"], worktree.path)).split("\0").filter(Boolean);
|
|
91
85
|
item.ignored = status.filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
|
|
92
86
|
item.dirty = status.some((entry) => !entry.startsWith("!! "));
|
|
93
87
|
if (!item.dirty) {
|
|
@@ -97,7 +91,7 @@ export async function inspectDiscardResource(primary, mainCwd, resource, guards,
|
|
|
97
91
|
item.blockers.push(flags.failure ?? "Index flags hide tracked files; dirty state cannot be proved.");
|
|
98
92
|
}
|
|
99
93
|
}
|
|
100
|
-
const submodules = await
|
|
94
|
+
const submodules = await requireGit(run, ["submodule", "status", "--recursive"], worktree.path);
|
|
101
95
|
if (submodules.split("\n").some((line) => line && !line.startsWith("-"))) {
|
|
102
96
|
item.blockers.push("Initialized submodules require separate inspection of their commits and ignored files.");
|
|
103
97
|
}
|
|
@@ -198,11 +192,11 @@ export async function removeDiscardResources(handle, primary, mainCwd, reviewed,
|
|
|
198
192
|
await handle.save();
|
|
199
193
|
await verify();
|
|
200
194
|
if (kind === "worktree" && item.checkout) {
|
|
201
|
-
await
|
|
195
|
+
await requireGit(run, ["worktree", "remove", "--force", item.path], primary);
|
|
202
196
|
checkoutRemoved = true;
|
|
203
197
|
}
|
|
204
198
|
else if (kind === "branch" && item.tip) {
|
|
205
|
-
await
|
|
199
|
+
await requireGit(run, ["update-ref", "--no-deref", "-d", `refs/heads/${item.branch}`, item.tip], primary);
|
|
206
200
|
}
|
|
207
201
|
step.status = "completed";
|
|
208
202
|
if (resource.textAttempt)
|
package/dist/prune.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type PruneRemoval = {
|
|
|
32
32
|
item: PruneItem;
|
|
33
33
|
reason: string;
|
|
34
34
|
};
|
|
35
|
+
export declare function requireGit(run: GitRunner, args: string[], cwd: string): Promise<string>;
|
|
36
|
+
export declare const within: (directory: string, path: string) => boolean;
|
|
35
37
|
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
36
38
|
export declare function listHerdrPanes(cwd: string): Promise<{
|
|
37
39
|
paneId: string;
|
package/dist/prune.js
CHANGED
|
@@ -6,13 +6,13 @@ import { runProcess } from "./process.js";
|
|
|
6
6
|
import { inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
7
7
|
const HERDR_TIMEOUT_MS = 30_000;
|
|
8
8
|
const IN_PROGRESS_MARKERS = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
|
|
9
|
-
async function requireGit(run, args, cwd) {
|
|
9
|
+
export async function requireGit(run, args, cwd) {
|
|
10
10
|
const result = await run(args, cwd);
|
|
11
11
|
if (result.code !== 0)
|
|
12
12
|
throw new Error(`git ${args[0]} failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
13
13
|
return result.stdout.trimEnd();
|
|
14
14
|
}
|
|
15
|
-
const within = (directory, path) => path === directory || path.startsWith(`${directory}${sep}`);
|
|
15
|
+
export const within = (directory, path) => path === directory || path.startsWith(`${directory}${sep}`);
|
|
16
16
|
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
17
17
|
export async function listHerdrPanes(cwd) {
|
|
18
18
|
const response = await createHerdrClient(runProcess).json(["pane", "list"], { cwd, timeoutMs: HERDR_TIMEOUT_MS });
|
package/dist/runner.d.ts
CHANGED
|
@@ -290,6 +290,8 @@ export declare function formatTextTaskContexts(contexts: readonly TextTaskContex
|
|
|
290
290
|
/** Build a bounded text-task assignment with upstream output marked as task data. */
|
|
291
291
|
export declare function buildTextTaskPrompt(goal: ExecuteRequest["goal"], task: Pick<TaskRequest, "id" | "requirements" | "deliverable">, contexts: readonly TextTaskContext[]): string;
|
|
292
292
|
export declare function readyPendingTasks(state: RunState): TaskState[];
|
|
293
|
+
/** Main's next integration step while the request waits only for Main; undefined when a task or the final gate needs attention. */
|
|
294
|
+
export declare function nextIntegrationAction(state: RunState): string | undefined;
|
|
293
295
|
export declare class IsolatedRunner {
|
|
294
296
|
private readonly coordinatorRuntime;
|
|
295
297
|
private readonly hostRuntime;
|
package/dist/runner.js
CHANGED
|
@@ -534,6 +534,68 @@ export function buildTextTaskPrompt(goal, task, contexts) {
|
|
|
534
534
|
export function readyPendingTasks(state) {
|
|
535
535
|
return state.tasks.filter((task) => task.status === "pending" && taskDependenciesCompleted(state, task.taskId));
|
|
536
536
|
}
|
|
537
|
+
const ADVANCE_HINT = "Main may call subagent_integrate advance with this generation and its exact staged combinedTip to launch ready dependents.";
|
|
538
|
+
const RUNNING_TASK_STATUSES = ["running", "allocating", "working", "integrating", "cleanup"];
|
|
539
|
+
function activeGeneration(state) {
|
|
540
|
+
const generation = state.integration.generations.at(-1);
|
|
541
|
+
return generation?.status === "superseded" ? undefined : generation;
|
|
542
|
+
}
|
|
543
|
+
/** Undecided retained candidates that Main can still stage into the latest open generation. */
|
|
544
|
+
function stageableCandidates(state) {
|
|
545
|
+
const generation = activeGeneration(state);
|
|
546
|
+
if (generation && generation.status !== "staging")
|
|
547
|
+
return [];
|
|
548
|
+
return state.integration.candidates.filter((candidate) => !candidate.decision && candidate.worker === "retained"
|
|
549
|
+
&& !generation?.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt));
|
|
550
|
+
}
|
|
551
|
+
function advanceReady(state) {
|
|
552
|
+
const generation = state.integration.generations.at(-1);
|
|
553
|
+
return generation?.status === "staging" && readyPendingTasks(state).some((task) => generation.combinedTip
|
|
554
|
+
&& hasChangesetDependency(state, task.taskId) && stagedDependencySnapshot(state, task.taskId, generation.combinedTip));
|
|
555
|
+
}
|
|
556
|
+
/** Main's next integration step while the request waits only for Main; undefined when a task or the final gate needs attention. */
|
|
557
|
+
export function nextIntegrationAction(state) {
|
|
558
|
+
if (state.status !== "needs_attention" || state.final.failure
|
|
559
|
+
|| state.tasks.some((task) => task.status === "needs_attention"))
|
|
560
|
+
return undefined;
|
|
561
|
+
const stageable = stageableCandidates(state);
|
|
562
|
+
if (stageable.length) {
|
|
563
|
+
return `Stage ${stageable.map((item) => `${item.taskId}#${item.attempt}`).join(", ")} with subagent_stage using exact status identities; Main remains unchanged.`;
|
|
564
|
+
}
|
|
565
|
+
const generation = activeGeneration(state);
|
|
566
|
+
if (!generation)
|
|
567
|
+
return undefined;
|
|
568
|
+
const exact = `generation ${generation.number} and expectedTip ${JSON.stringify(generation.combinedTip)}`;
|
|
569
|
+
const worktree = generation.worktree?.path ?? "allocation not proven";
|
|
570
|
+
switch (generation.status) {
|
|
571
|
+
case "staging":
|
|
572
|
+
if (!generation.combinedTip || generation.stages.some((stage) => stage.status !== "staged"))
|
|
573
|
+
return undefined;
|
|
574
|
+
if (advanceReady(state))
|
|
575
|
+
return ADVANCE_HINT;
|
|
576
|
+
if (state.tasks.some((task) => RUNNING_TASK_STATUSES.includes(task.status)))
|
|
577
|
+
return undefined;
|
|
578
|
+
return `Every candidate is staged; run subagent_integrate validate with ${exact}.`;
|
|
579
|
+
case "validating":
|
|
580
|
+
return `Combined validation of generation ${generation.number} is running; wait for its follow-up.`;
|
|
581
|
+
case "ready":
|
|
582
|
+
return `Combined validation passed; promote with subagent_integrate promote using ${exact}.`;
|
|
583
|
+
case "conflict": {
|
|
584
|
+
const stage = generation.stages.at(-1);
|
|
585
|
+
return `Resolve and commit the merge of ${stage.taskId}#${stage.attempt} in the retained integration worktree ${worktree}, then call subagent_stage resolve with generation ${generation.number}, its exact candidate, and expectedTip ${JSON.stringify(stage.onto)}.`;
|
|
586
|
+
}
|
|
587
|
+
case "validation_failed":
|
|
588
|
+
if (generation.checks && !generation.correction && (!generation.checks.passed || generation.review?.passed === false)) {
|
|
589
|
+
return `Fix and commit the combined failure in the retained integration worktree ${worktree}, then call subagent_integrate correct with ${exact}.`;
|
|
590
|
+
}
|
|
591
|
+
if (!generation.checks && !generation.review && !generation.correction && generation.combinedTip) {
|
|
592
|
+
return `Call subagent_integrate refresh with generation ${generation.number + 1}, expectedTip ${JSON.stringify(generation.combinedTip)}, and identical expectedMain and newMain; then restage the candidates and validate again.`;
|
|
593
|
+
}
|
|
594
|
+
return undefined;
|
|
595
|
+
default:
|
|
596
|
+
return undefined;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
537
599
|
function isDeadline(error, scope) {
|
|
538
600
|
return error instanceof DeadlineExpired || scope.signal.reason instanceof DeadlineExpired;
|
|
539
601
|
}
|
|
@@ -3203,6 +3265,9 @@ export class IsolatedRunner {
|
|
|
3203
3265
|
const attention = continuation && "taskId" in continuation
|
|
3204
3266
|
? taskState(state, continuation.taskId)
|
|
3205
3267
|
: state.tasks.find((task) => task.status === "needs_attention");
|
|
3268
|
+
const next = nextIntegrationAction(state);
|
|
3269
|
+
const stageable = stageableCandidates(state).length > 0;
|
|
3270
|
+
const advance = advanceReady(state);
|
|
3206
3271
|
return {
|
|
3207
3272
|
text: bounded([
|
|
3208
3273
|
`Pi Subagent ${state.request.id}: ${state.status}.`,
|
|
@@ -3218,13 +3283,11 @@ export class IsolatedRunner {
|
|
|
3218
3283
|
...(main?.status === "unavailable" ? [`Main: ${main.failure}`] : []),
|
|
3219
3284
|
...(attention?.failure ? [`Needs attention (${attention.taskId}): ${attention.failure}`] : []),
|
|
3220
3285
|
...(state.integration.candidates.length && state.integration.generations.at(-1)?.status !== "promoted" ? [
|
|
3221
|
-
`Candidates: ${state.integration.candidates.map((item) => `${item.taskId}#${item.attempt}@${item.tip.head}${item.decision ? " (rejected)" : ""}`).join(", ")}
|
|
3286
|
+
`Candidates: ${state.integration.candidates.map((item) => `${item.taskId}#${item.attempt}@${item.tip.head}${item.decision ? " (rejected)" : ""}`).join(", ")}.${stageable ? " Use subagent_stage with exact status identities; Main remains unchanged." : ""}`,
|
|
3222
3287
|
] : []),
|
|
3223
3288
|
...(state.integration.generations.at(-1) ? [
|
|
3224
3289
|
`Integration generation ${state.integration.generations.at(-1).number}: ${state.integration.generations.at(-1).status}; worktree: ${state.integration.generations.at(-1).worktree?.path ?? "allocation not proven"}; stage: ${state.integration.generations.at(-1).stages.at(-1)?.status ?? "none"}.`,
|
|
3225
|
-
...(
|
|
3226
|
-
&& stagedDependencySnapshot(state, task.taskId, state.integration.generations.at(-1).combinedTip))
|
|
3227
|
-
? ["Main may call subagent_integrate advance with this generation and its exact staged combinedTip to launch ready dependents."] : []),
|
|
3290
|
+
...(advance ? [ADVANCE_HINT] : []),
|
|
3228
3291
|
...(state.integration.generations.at(-1).failure ? [`Integration: ${state.integration.generations.at(-1).failure}`] : []),
|
|
3229
3292
|
...(state.integration.generations.at(-1).promotion?.failure ? [`Promotion: ${state.integration.generations.at(-1).promotion.failure}`] : []),
|
|
3230
3293
|
...(state.integration.generations.at(-1).checks?.passed === false ? ["Combined checks failed; Main was not changed."] : []),
|
|
@@ -3238,6 +3301,7 @@ export class IsolatedRunner {
|
|
|
3238
3301
|
"Rejected candidate resources remain retained. Release every generation that staged it, then use subagent_integrate release with the exact task, attempt and candidate tip; release first proves or performs exact worker termination.",
|
|
3239
3302
|
] : []),
|
|
3240
3303
|
...(state.final.failure ? [`Final: ${state.final.failure}`] : []),
|
|
3304
|
+
...(next && !stageable && !advance ? [`Next: ${next}`] : []),
|
|
3241
3305
|
...(continuation ? [`Continuation: ${JSON.stringify(continuation)}`] : []),
|
|
3242
3306
|
].join("\n")),
|
|
3243
3307
|
state,
|
package/dist/schema.d.ts
CHANGED
|
@@ -2391,6 +2391,7 @@ export declare const StatusOutputSchema: Type.TObject<{
|
|
|
2391
2391
|
scope: Type.TUnion<[Type.TLiteral<"task">, Type.TLiteral<"final">, Type.TLiteral<"request">]>;
|
|
2392
2392
|
taskId: Type.TOptional<Type.TString>;
|
|
2393
2393
|
failure: Type.TOptional<Type.TString>;
|
|
2394
|
+
nextAction: Type.TOptional<Type.TString>;
|
|
2394
2395
|
failedReview: Type.TOptional<Type.TObject<{
|
|
2395
2396
|
verdict: Type.TString;
|
|
2396
2397
|
verdictTruncated: Type.TBoolean;
|
package/dist/schema.js
CHANGED
|
@@ -467,6 +467,7 @@ export const StatusOutputSchema = Type.Object({
|
|
|
467
467
|
scope: Type.Union([Type.Literal("task"), Type.Literal("final"), Type.Literal("request")]),
|
|
468
468
|
taskId: Type.Optional(IdSchema),
|
|
469
469
|
failure: Type.Optional(Type.String()),
|
|
470
|
+
nextAction: Type.Optional(Type.String()),
|
|
470
471
|
failedReview: Type.Optional(Type.Object({ verdict: Type.String(), verdictTruncated: Type.Boolean() })),
|
|
471
472
|
})),
|
|
472
473
|
createdAt: TimestampSchema,
|
package/dist/store.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export declare class FileRunStore {
|
|
|
44
44
|
/** Address saved state without resolving a parent checkout that may no longer exist. */
|
|
45
45
|
private validateDirectory;
|
|
46
46
|
loadFromDirectory(directory: string, id: string): Promise<RunStateHandle>;
|
|
47
|
+
private loadFromValidatedDirectory;
|
|
47
48
|
hasProductiveRunLeaseInDirectory(directory: string): Promise<boolean>;
|
|
48
49
|
withDirectoryLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
|
|
49
50
|
/** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
|
package/dist/store.js
CHANGED
|
@@ -227,6 +227,9 @@ export class FileRunStore {
|
|
|
227
227
|
}
|
|
228
228
|
async loadFromDirectory(directory, id) {
|
|
229
229
|
await this.validateDirectory(directory);
|
|
230
|
+
return await this.loadFromValidatedDirectory(directory, id);
|
|
231
|
+
}
|
|
232
|
+
async loadFromValidatedDirectory(directory, id) {
|
|
230
233
|
if (basename(id) !== id || !id || id === "." || id === "..")
|
|
231
234
|
throw new Error("Invalid request ID.");
|
|
232
235
|
const path = join(directory, `${id}.json`);
|
|
@@ -274,14 +277,18 @@ export class FileRunStore {
|
|
|
274
277
|
const directory = join(parent, entry.name);
|
|
275
278
|
try {
|
|
276
279
|
await this.validateDirectory(directory);
|
|
280
|
+
// Every state in a directory shares one root; a live root means no orphan can be here.
|
|
281
|
+
const invalid = [];
|
|
282
|
+
let live = false;
|
|
277
283
|
for (const file of await readdir(directory)) {
|
|
278
284
|
if (!file.endsWith(".json"))
|
|
279
285
|
continue;
|
|
280
286
|
try {
|
|
281
|
-
const handle = await this.
|
|
287
|
+
const handle = await this.loadFromValidatedDirectory(directory, file.slice(0, -5));
|
|
282
288
|
try {
|
|
283
289
|
await lstat(handle.state.root);
|
|
284
|
-
|
|
290
|
+
live = true;
|
|
291
|
+
break;
|
|
285
292
|
}
|
|
286
293
|
catch (error) {
|
|
287
294
|
if (!isMissing(error))
|
|
@@ -292,9 +299,11 @@ export class FileRunStore {
|
|
|
292
299
|
states.push(handle);
|
|
293
300
|
}
|
|
294
301
|
catch {
|
|
295
|
-
|
|
302
|
+
invalid.push(join(directory, file));
|
|
296
303
|
}
|
|
297
304
|
}
|
|
305
|
+
if (!live)
|
|
306
|
+
invalidIds.push(...invalid);
|
|
298
307
|
}
|
|
299
308
|
catch {
|
|
300
309
|
invalidIds.push(directory);
|
package/docs/orchestration.md
CHANGED
|
@@ -353,7 +353,7 @@ Without retained candidates, abort terminates exact owned workers and removes on
|
|
|
353
353
|
|
|
354
354
|
### Recover after interruption
|
|
355
355
|
|
|
356
|
-
After a coordinator interruption, run `/subagent recover` one time. It gets the repository productive lease before it changes any
|
|
356
|
+
After a coordinator interruption, run `/subagent recover` one time. It gets the repository productive lease before it changes any interrupted `pending` or `running` state. It preserves unreadable state files and retained candidates. It reports blockers and existing continuations to Main. It does not replay prompts, merges, promotion, or cleanup.
|
|
357
357
|
|
|
358
358
|
If another productive run holds the lease, the command only reads status and reports the live owner. Wait. Do not take over. You can safely run the command again.
|
|
359
359
|
|
|
@@ -522,4 +522,4 @@ Worktree finalization returns one of:
|
|
|
522
522
|
- `retained`: exact commit and dirty measurements are known and work remains; or
|
|
523
523
|
- `recovery`: cleanup or measurement needs deliberate action and reports only proved fields.
|
|
524
524
|
|
|
525
|
-
No cleanup path uses `git clean`, force deletion, hidden fallback to Main, or deletion of unproved work.
|
|
525
|
+
No cleanup path uses `git clean`, force deletion, hidden fallback to Main, or deletion of unproved work. The one exception is user-confirmed discard: after the user selects **Close and delete worktrees (discard work)** in `/subagent` and confirms, it force-removes the reviewed owned worktrees.
|
package/examples/roles/scout.md
CHANGED
|
@@ -6,6 +6,7 @@ tools:
|
|
|
6
6
|
- grep
|
|
7
7
|
- find
|
|
8
8
|
- ls
|
|
9
|
+
- git_read
|
|
9
10
|
extensions: []
|
|
10
11
|
skills: []
|
|
11
12
|
mcps: []
|
|
@@ -15,7 +16,7 @@ Answer only the bounded discovery questions.
|
|
|
15
16
|
|
|
16
17
|
Read applicable repository instructions and domain context first. Trace the relevant execution/data flow, callers, tests, and constraints only far enough to answer. Separate observed facts, supported inferences, and unknowns. Stop when answered; if blocked, state what is missing.
|
|
17
18
|
|
|
18
|
-
Do not design, recommend, implement, edit, or run shell commands.
|
|
19
|
+
Do not design, recommend, implement, edit, or run shell commands. For Git inspection of commits and diffs, use only `git_read`.
|
|
19
20
|
|
|
20
21
|
Return concisely:
|
|
21
22
|
|
package/extensions/admission.ts
CHANGED
|
@@ -25,6 +25,16 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
|
|
|
25
25
|
const heldCalls = new Set<string>();
|
|
26
26
|
const ownerByCheckout = new Map<string, string>();
|
|
27
27
|
const calls = new Map<string, { direct: boolean; key?: string }>();
|
|
28
|
+
// One Git lookup per working directory per session; a failed lookup is retried.
|
|
29
|
+
const checkouts = new Map<string, Promise<string>>();
|
|
30
|
+
const lookup = (ctx: ExtensionContext) => {
|
|
31
|
+
const cached = checkouts.get(ctx.cwd);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
const key = checkoutKey(pi, ctx);
|
|
34
|
+
checkouts.set(ctx.cwd, key);
|
|
35
|
+
key.catch(() => { if (checkouts.get(ctx.cwd) === key) checkouts.delete(ctx.cwd); });
|
|
36
|
+
return key;
|
|
37
|
+
};
|
|
28
38
|
const release = (toolCallId: string) => {
|
|
29
39
|
if (heldCalls.has(toolCallId)) return;
|
|
30
40
|
const call = calls.get(toolCallId);
|
|
@@ -36,7 +46,7 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
|
|
|
36
46
|
if (direct ? !directCanWrite(event.input) : MAIN_NON_WRITING_TOOLS.has(event.toolName)) return;
|
|
37
47
|
const call: { direct: boolean; key?: string } = { direct };
|
|
38
48
|
calls.set(event.toolCallId, call);
|
|
39
|
-
const key = await
|
|
49
|
+
const key = await lookup(ctx);
|
|
40
50
|
if (calls.get(event.toolCallId) !== call) {
|
|
41
51
|
return { block: true, reason: `Call ${event.toolCallId} already settled; use mode: isolated to keep Main free.` };
|
|
42
52
|
}
|
|
@@ -54,7 +64,9 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
|
|
|
54
64
|
});
|
|
55
65
|
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
56
66
|
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
67
|
+
pi.on("session_start", () => checkouts.clear());
|
|
57
68
|
pi.on("session_shutdown", () => {
|
|
69
|
+
checkouts.clear();
|
|
58
70
|
// Session replacement is not proof that a held direct worker stopped.
|
|
59
71
|
for (const toolCallId of calls.keys()) release(toolCallId);
|
|
60
72
|
});
|
package/extensions/isolated.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { HerdrHostRuntime } from "../dist/herdr-runtime.js";
|
|
|
16
16
|
import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type PruneGuards, type PruneItem } from "../dist/prune.js";
|
|
17
17
|
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
18
18
|
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
19
|
-
import { IsolatedRunner, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
19
|
+
import { IsolatedRunner, nextIntegrationAction, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
20
20
|
import {
|
|
21
21
|
IdOnlySchema,
|
|
22
22
|
ResumeRequestParameters,
|
|
@@ -289,9 +289,10 @@ function publicNeedsAttention(state: RunState, verdictMaxBytes: number, preferre
|
|
|
289
289
|
...(failedReview ? { failedReview } : {}),
|
|
290
290
|
};
|
|
291
291
|
}
|
|
292
|
+
const nextAction = nextIntegrationAction(state);
|
|
292
293
|
return {
|
|
293
294
|
scope: "request" as const,
|
|
294
|
-
failure: "Request needs attention without a task or final failure.",
|
|
295
|
+
...(nextAction ? { nextAction } : { failure: "Request needs attention without a task or final failure." }),
|
|
295
296
|
};
|
|
296
297
|
}
|
|
297
298
|
|
|
@@ -395,7 +396,8 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
|
|
|
395
396
|
: `Main ${main.status}: ${JSON.stringify({ expected: main.expected, actual: main.actual })}`);
|
|
396
397
|
if (state.integration.refresh) lines.push(`Integration refresh: ${state.integration.refresh.status}${state.integration.refresh.failure ? `; blockage: ${boundedPublicText(state.integration.refresh.failure)}` : ""}.`);
|
|
397
398
|
const attention = publicNeedsAttention(state, PUBLIC_EVIDENCE_MAX_BYTES);
|
|
398
|
-
if (attention
|
|
399
|
+
if (attention && "nextAction" in attention) lines.push(`Next integration action: ${attention.nextAction}`);
|
|
400
|
+
else if (attention) lines.push(`Reported blockage: ${JSON.stringify({ scope: attention.scope, failure: attention.failure, failedCheck: "failedCheck" in attention ? attention.failedCheck : undefined, failedReview: "failedReview" in attention ? attention.failedReview : undefined })}`);
|
|
399
401
|
if (state.final.failure) lines.push(`Final blockage: ${boundedPublicText(state.final.failure)}`);
|
|
400
402
|
for (const task of state.tasks) {
|
|
401
403
|
lines.push(`Task ${task.taskId}: ${task.status}${task.failure ? `; blockage: ${boundedPublicText(task.failure)}` : ""}.`);
|
package/package.json
CHANGED
|
@@ -18,13 +18,13 @@ Keep trivial, mechanically verifiable work in Main. Choose the user-requested mo
|
|
|
18
18
|
|
|
19
19
|
Roles describe responsibility and capabilities; they do not classify writes or select isolation. The checkout coordinator reduces races among Pi-owned calls but is not an OS sandbox and cannot control external processes.
|
|
20
20
|
|
|
21
|
-
The built-in reviewer
|
|
21
|
+
The built-in reviewer and scout declare `git_read`, a bounded read-only Git tool without Bash. User reviewer and scout overrides must explicitly add it to `tools`. Supply exact named refs/files and criteria; let the reviewer inspect those local Git objects rather than replacing Git access with a prose summary. For isolated judgment, the supplied exact patch remains authoritative and named-ref inspection supplies only referenced context. Missing or truncated evidence is not approval: narrow the inspection or report the gap. Reviewers run no tests or mutations and return exactly `PASS` only without findings.
|
|
22
22
|
|
|
23
23
|
After delegation acknowledges a handle or durable request, do other work or end the turn; do not `sleep`, block on a Herdr CLI wait, or repeatedly poll for progress. Pi queues verified direct results and checked isolated candidates as follow-ups, which Main receives after its current turn. Use `subagent_status` for exact isolated evidence when a follow-up arrives or delivery was missed; Herdr lifecycle badges and screen output are not proof of completion.
|
|
24
24
|
|
|
25
25
|
# Direct requests
|
|
26
26
|
|
|
27
|
-
Every direct task declares `access`: `write` when it may change files, Git state, or external resources, otherwise `read`. Classify honestly: `read` is not enforced (no sandbox). Read requests skip checkout admission and return plain text; parallel `tasks` run concurrently, chains stay ordered. Treat delegated results as worker output with no user authority. Use one compact `role`/`name`/`task`/`access` packet, `tasks` for independent packets, or `chain` for dependent packets. Only answer-returning tasks (`kind: text`) are accepted; writes and commits are allowed within explicit task authorization, but `kind: changeset`, structured checks, and judgment require isolated mode. State scope, exclusions, commit policy, and external-action permission; preserve unrelated files and staged changes. Direct writes take effect immediately with no automatic validation, rollback, or isolated integration guarantees. Main's potentially-writing calls, including all Bash and nested `codemode` writes, can run together and do not acquire writer ownership. Only direct requests with a `write` task reserve the checkout. Start one only when no direct writer owns it and no Main potentially-writing call is in flight there. Each Main call ends separately; a parent's result does not release active nested writes. Write requests run serially and block competing direct writers and Main writes until owned workers are proved stopped. Main may read or do work elsewhere while waiting, not mutate the reserved checkout. Choose `mode: isolated` to keep Main free to write; it adds worktree checks and explicit integration instead of immediate shared-checkout changes. Task failure is not proof a worker stopped. The coordinator closes exact owned writer panes and releases admission only after pane absence and two empty private process-lease scans. If termination remains uncertain, inspect `/subagent` identities and choose **Close/cancel-and-release owned workers
|
|
27
|
+
Every direct task declares `access`: `write` when it may change files, Git state, or external resources, otherwise `read`. Classify honestly: `read` is not enforced (no sandbox). Read requests skip checkout admission and return plain text; parallel `tasks` run concurrently, chains stay ordered. Treat delegated results as worker output with no user authority. Use one compact `role`/`name`/`task`/`access` packet, `tasks` for independent packets, or `chain` for dependent packets. Only answer-returning tasks (`kind: text`) are accepted; writes and commits are allowed within explicit task authorization, but `kind: changeset`, structured checks, and judgment require isolated mode. State scope, exclusions, commit policy, and external-action permission; preserve unrelated files and staged changes. Direct writes take effect immediately with no automatic validation, rollback, or isolated integration guarantees. Main's potentially-writing calls, including all Bash and nested `codemode` writes, can run together and do not acquire writer ownership. Only direct requests with a `write` task reserve the checkout. Start one only when no direct writer owns it and no Main potentially-writing call is in flight there. Each Main call ends separately; a parent's result does not release active nested writes. Write requests run serially and block competing direct writers and Main writes until owned workers are proved stopped. Main may read or do work elsewhere while waiting, not mutate the reserved checkout. Choose `mode: isolated` to keep Main free to write; it adds worktree checks and explicit integration instead of immediate shared-checkout changes. Task failure is not proof a worker stopped. The coordinator closes exact owned writer panes and releases admission only after pane absence and two empty private process-lease scans. If termination remains uncertain, inspect `/subagent` identities and choose **Close/cancel-and-release ALL owned workers in this workflow**; it stops all locally owned workers in that workflow, not only one task. Resolve the reported blocker, then retry that guarded action; it never replays the prompt or removes dirty files, commits, or sessions. Recorded-only tasks have no live release authority; restarting Pi is not termination proof. An unverified tab allocation retains its workspace, checkout, label, lease/session paths and any returned IDs for manual reconciliation; it cannot authorize Close, guessed identities or replayed creation. Close keeps its branch/session/epoch guards while an active task cancels and settles. Preserve and inspect changes before writing or retrying. A failed commit may already have happened; never replay it blindly. The tool returns a handle after launching Herdr tabs in the current workspace. Main receives the verified result as a follow-up after the workflow finishes, without interrupting an active turn. If observation stops, open `/subagent` to inspect exact recorded tabs and session files on this session branch; recorded does not prove still running.
|
|
28
28
|
|
|
29
29
|
To build on a finished direct worker, add `continueFrom` with its reference (such as `D7.1`) and the same `role`. The worker resumes its saved Pi session in a new tab, so continue it for follow-up questions, corrections, or the next step on the same evidence. Start a fresh task for unrelated work, a different Role, or when the earlier context is stale, wrong, or large. Continuation fails before launch for an unknown reference, a different Role, a worker still owned by an active task, a missing or oversized session, an unfinished last turn, or a busy old agent; do not retry it blindly. The user can stop one running worker of a read-only workflow with **Stop this worker** in `/subagent`; that entry fails with `Stopped by user from /subagent` and its siblings continue.
|
|
30
30
|
|
|
@@ -36,6 +36,6 @@ Every changeset becomes ready automatically after preliminary checks pass. While
|
|
|
36
36
|
|
|
37
37
|
A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip`: it acknowledges saved intent, runs full root checks and optional final judgment in the session-owned background, then sends a follow-up. Acknowledgement is not passing evidence; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
|
|
38
38
|
|
|
39
|
-
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once; if another productive run holds the lease, wait and run it again. It acquires the productive lease, classifies
|
|
39
|
+
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once; if another productive run holds the lease, wait and run it again. It acquires the productive lease, classifies interrupted work, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. For leftover merged worker or integration checkouts, ask the user to run `/subagent prune`; it removes only reviewed, confirmed, merged, clean, unowned items. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. For a retry that sends a same-worker correction, pass `instruction` to name the exact fixes, including the review or check findings the worker must address; the correction prompt carries only a short saved failure. Follow-ups show a short preview of a failed review verdict; `subagent_status` returns it in full. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. To delete owned isolated worktrees, ask the user to run `/subagent` and choose **Close and delete worktrees (discard work)**; agents cannot trigger it. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
40
40
|
|
|
41
41
|
All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Compose generic workflows outside the ephemeral executor
|
|
2
|
-
|
|
3
|
-
## Decision
|
|
4
|
-
|
|
5
|
-
The public task executor is an execution mechanism: it receives a prepared Pi Launch, runs one bounded Delegated Task, and returns the result. The `delegate_task` extension composes it for direct workflows and isolated checked graphs; those modes are not executor primitives.
|
|
6
|
-
|
|
7
|
-
Generic callers compose workflows with JavaScript. Fan-out and fan-in use promises and collections; sequencing uses ordinary control flow; review loops use explicit caller-owned bounds. The package does not define a recursive workflow AST.
|
|
8
|
-
|
|
9
|
-
Resource Policy is split at launch preparation:
|
|
10
|
-
|
|
11
|
-
- Pi Subagent alone parses and resolves effective Roles.
|
|
12
|
-
- Role owns base tools, extension capability bundles, Skill names, and an exact MCP server allowlist.
|
|
13
|
-
- Caller may add explicit tools, extensions, and environment through `createRoleLaunch`.
|
|
14
|
-
- `resolveConfiguredRoleLaunch` reloads the current Role, package resources, Skill registry, model route, and MCP policy for each launch.
|
|
15
|
-
- The prepared launch keeps the Role system prompt separate from prompt-free argv until the caller's final launch boundary.
|
|
16
|
-
- The executor receives the resulting Pi Launch and does not discover resources.
|
|
17
|
-
|
|
18
|
-
Built-in `implementer`, `reviewer`, and `scout` Roles ship as Markdown in `examples/roles/` and use the same parser as user Roles. For generic delegation, a same-named user Role explicitly overrides a built-in. The package does not install, copy, or write user configuration.
|
|
19
|
-
|
|
20
|
-
An explicit call `modelClass` selects a task-model route. Without one, an optional Role `modelClass` default wins; without either, the caller's Model Task assignment or declared default selects the route. The route owns its exact thinking level. A direct `model` replaces only the route model and must honor that level.
|
|
21
|
-
|
|
22
|
-
## Scope boundary
|
|
23
|
-
|
|
24
|
-
The executor remains generic and policy-free beyond child execution limits. Direct and isolated workflow policy belongs to the extension and checked runner, not the executor. External library callers may still compose the mechanism with their own protocols.
|
|
25
|
-
|
|
26
|
-
## Consequences
|
|
27
|
-
|
|
28
|
-
The executor remains stable while pi-subagent owns its direct and checked-isolated semantics. Mechanism callers continue to own any protocol they build outside those surfaces.
|
package/docs/migration.md
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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.
|