@henryqw/pi-subagent 27.0.0 → 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/CONTEXT.md +1 -1
- package/README.md +17 -4
- package/dist/herdr-runtime.js +3 -1
- package/dist/integration-git.js +3 -3
- package/dist/prune.d.ts +50 -0
- package/dist/prune.js +192 -0
- package/dist/runner.d.ts +13 -0
- package/dist/runner.js +104 -10
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -5
- package/dist/store.d.ts +2 -0
- package/dist/store.js +14 -2
- package/dist/worktree.d.ts +3 -0
- package/dist/worktree.js +3 -1
- package/docs/orchestration.md +4 -2
- package/extensions/isolated.ts +113 -36
- package/extensions/result-transport.ts +22 -2
- package/extensions/subagent-command.ts +88 -2
- package/extensions/subagent.ts +15 -5
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +2 -2
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
|
-
|
|
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 (
|
|
1084
|
-
throw new Error(`
|
|
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, `
|
|
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
|
|
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);
|
package/dist/worktree.d.ts
CHANGED
|
@@ -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;
|
package/docs/orchestration.md
CHANGED
|
@@ -112,7 +112,7 @@ The user's request can authorize the declared local graph, checks, review, and M
|
|
|
112
112
|
|
|
113
113
|
`/subagent` opens a native picker for direct recovery and isolated inspection; locally owned active changesets may queue an optional same-worker revision or drain all still-pending instructions into the native editor and submit one replacement. Cancel after draining leaves them withdrawn; claimed instructions are unaffected. Pi's global Option+Up is unchanged. There is no guaranteed post-completion editing window; late follow-ups fail visibly. After readiness, use `subagent_stage revise` with an exact candidate, generation, current `expectedTip`, and instruction when a same-worker correction remains allowed. Material scope growth, missing authorization, user-owned conflicts, or consequential external actions still require a new decision. pi-subagent never pushes, publishes, deploys, or opens a pull request.
|
|
114
114
|
|
|
115
|
-
`subagent_status` reports ready candidates with `taskId`, `attempt`, and `tip`, plus each generation's number, stage lineage, combined tip, checks, promotion, and retained worktree. Use those exact identities (including branch, HEAD, index, and tree) rather than guessed commits:
|
|
115
|
+
`subagent_status` puts failed review verdicts from every task's latest attempt (also while siblings run), the final gate, and each generation into its model-visible text within one shared 32 KiB budget and counts omitted verdicts; its structured projection carries each verdict up to 32 KiB and sets `verdictTruncated` when it is cut; asynchronous follow-ups keep a short preview and point Main to `subagent_status`. It reports ready candidates with `taskId`, `attempt`, and `tip`, plus each generation's number, stage lineage, combined tip, checks, promotion, and retained worktree. Use those exact identities (including branch, HEAD, index, and tree) rather than guessed commits:
|
|
116
116
|
|
|
117
117
|
- `subagent_stage`: `{ id, action: "stage" | "resolve" | "reject" | "revise", generation, taskId, attempt, candidate, expectedTip, instruction? }`. `candidate` is the ready candidate's `tip`; `expectedTip` is Main's recorded identity for the first stage or the previous stage's exact tip. `resolve` reconciles a pending/conflicted merge; `reject` and `revise` arbitrate a candidate without changing Main.
|
|
118
118
|
- `subagent_integrate`: `{ id, action: "advance" | "validate" | "correct" | "promote" | "reconcile" | "cleanup", generation, expectedTip }`. Use the generation's exact `combinedTip`. `advance` dispatches ready dependents on that snapshot, `correct` records the single clean committed correction after a definitive failed check/review, `reconcile` inspects an interrupted validation or promotion without replaying an uncertain promotion, and `cleanup` completes proved post-promotion cleanup.
|
|
@@ -129,7 +129,9 @@ State version 5 is stored privately under:
|
|
|
129
129
|
<agent-dir>/config/pi-subagent/state/<repository-hash>/<request-id>.json
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. `subagent_abort` performs exact worker termination and conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
|
|
132
|
+
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. When a proved finished turn leaves tracked or untracked changes in a valid owned worktree, the runner sends the same worker one commit reminder per attempt, saved as a follow-up prompt record before delivery like any other prompt. It does not use a correction or a dispatch attempt. The runner never commits, stages, stashes, or discards; a commit from the reminder goes through the normal candidate path, and a worktree still dirty after it needs attention. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. A `retry` that sends a same-worker correction may add an `instruction` (at most 32000 exact characters, no NUL), appended to the correction prompt as Main's instruction after the saved failure and saved in its prompt record before delivery; other actions reject it. `subagent_abort` performs exact worker termination and conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
|
|
133
|
+
|
|
134
|
+
`/subagent prune` reviews every `pi-subagent/subagent-<24 hex>` branch in the repository and its `.worktrees/` checkout. An item is removable only when its tip is an ancestor of Main's HEAD or the local `origin` default-branch ref (no fetch), the checkout is still on its own branch (a detached or switched owned checkout is found by path and blocks), has no tracked or untracked changes, no initialized submodules, and no in-progress Git operation, no non-terminal or unreadable state in any of the repository's checkouts records its name (a non-regular state entry counts as unreadable and may own any name), no productive lease is live, and no Herdr pane works inside the checkout (unavailable Herdr refuses). Removal needs interactive confirmation; checkouts with ignored files need a second confirmation that names each ignored entry in full, and a checkout whose list does not fit is left out; that approval covers each entry with all its contents, and a new or changed top-level ignored entry skips the checkout. Prunable items are listed first, and only items shown in full in both dialogs can be removed; an over-long list ends with an explicit `+N more not shown` line instead of a silent cut. The confirming session's ownership is rechecked immediately before each `git worktree remove` and branch deletion. Each item is re-verified immediately before `git worktree remove`, and its branch is deleted with a compare-and-swap on the reviewed tip. Aborted requests that recorded a removed resource are then reconciled by the guarded abort path, which proves absence; requests it cannot reconcile are reported. State files are never edited directly.
|
|
133
135
|
|
|
134
136
|
Text attempt history records dispatches, not pre-dispatch inspection failures. Such failures retain task-level attention without adding an attempt or rewriting earlier execution diagnostics. A recorded interrupted dispatch still consumes an attempt. Verification requires the same passing preliminary checks, judgment, and ownership as candidate retention; readiness and the verified candidate are saved together.
|
|
135
137
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
type DirectProcessRunner,
|
|
13
14
|
} from "../dist/git-runtime.js";
|
|
14
15
|
import { HerdrHostRuntime } from "../dist/herdr-runtime.js";
|
|
16
|
+
import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type PruneGuards, type PruneItem } from "../dist/prune.js";
|
|
15
17
|
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
16
18
|
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
17
19
|
import { IsolatedRunner, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
@@ -36,10 +38,12 @@ import {
|
|
|
36
38
|
} from "../dist/schema.js";
|
|
37
39
|
import { FileRunStore } from "../dist/store.js";
|
|
38
40
|
import { workerOutputForMain } from "./result-transport.ts";
|
|
39
|
-
import type { IsolatedInventory } from "./subagent-command.ts";
|
|
41
|
+
import type { IsolatedInventory, PrunePlan } from "./subagent-command.ts";
|
|
40
42
|
|
|
41
43
|
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
42
44
|
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
45
|
+
/** Tool results return failed review verdicts in full up to this bound; follow-ups keep the short preview. */
|
|
46
|
+
const PUBLIC_REVIEW_VERDICT_MAX_BYTES = 32 * 1024;
|
|
43
47
|
const WORKSPACE_WIDGET_KEY = "pi-subagent-workspaces";
|
|
44
48
|
const WIDGET_FIELD_MAX_CHARS = 32;
|
|
45
49
|
const MAX_WORKSPACE_WIDGET_LINES = 6;
|
|
@@ -175,10 +179,25 @@ function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, row
|
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
/** Join report lines within a follow-up budget, naming how many were omitted. */
|
|
183
|
+
function boundedReport(lines: readonly string[], omitted: string): string {
|
|
184
|
+
const report: string[] = [];
|
|
185
|
+
let bytes = 0;
|
|
186
|
+
let skipped = 0;
|
|
187
|
+
for (const line of lines.flatMap((entry) => entry.split("\n"))) {
|
|
188
|
+
const size = Buffer.byteLength(`${line}\n`, "utf8");
|
|
189
|
+
if (bytes + size > 7_900) { skipped++; continue; }
|
|
190
|
+
report.push(line);
|
|
191
|
+
bytes += size;
|
|
192
|
+
}
|
|
193
|
+
if (skipped) report.push(`[${skipped} ${omitted}]`);
|
|
194
|
+
return report.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function boundedPublicText(value: string, maxBytes = PUBLIC_EVIDENCE_MAX_BYTES): string {
|
|
198
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
199
|
+
let end = Math.min(value.length, maxBytes);
|
|
200
|
+
while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes) end -= 1;
|
|
182
201
|
return `${value.slice(0, end)}\n[truncated]`;
|
|
183
202
|
}
|
|
184
203
|
|
|
@@ -199,16 +218,18 @@ function publicFailedCheck(evidence: CheckBatchEvidence | undefined) {
|
|
|
199
218
|
};
|
|
200
219
|
}
|
|
201
220
|
|
|
202
|
-
function publicFailedReview(evidence: ReviewEvidence | undefined) {
|
|
221
|
+
function publicFailedReview(evidence: ReviewEvidence | undefined, verdictMaxBytes: number) {
|
|
203
222
|
if (!evidence || evidence.passed) return undefined;
|
|
223
|
+
const verdict = boundedPublicText(evidence.verdict, verdictMaxBytes);
|
|
204
224
|
return {
|
|
205
225
|
phase: evidence.phase,
|
|
206
|
-
verdict
|
|
226
|
+
verdict,
|
|
227
|
+
verdictTruncated: verdict !== evidence.verdict,
|
|
207
228
|
identityChanged: !sameIdentity(evidence.tip, evidence.identityAfter),
|
|
208
229
|
};
|
|
209
230
|
}
|
|
210
231
|
|
|
211
|
-
function publicTaskRecovery(task: RunState["tasks"][number]) {
|
|
232
|
+
function publicTaskRecovery(task: RunState["tasks"][number], verdictMaxBytes: number) {
|
|
212
233
|
if (task.kind === "text") {
|
|
213
234
|
const attempt = task.attempts.at(-1);
|
|
214
235
|
return {
|
|
@@ -225,7 +246,7 @@ function publicTaskRecovery(task: RunState["tasks"][number]) {
|
|
|
225
246
|
|
|
226
247
|
const attempt = task.attempts.at(-1);
|
|
227
248
|
const failedCheck = publicFailedCheck(attempt?.preliminaryChecks);
|
|
228
|
-
const failedReview = publicFailedReview(attempt?.preliminaryReview);
|
|
249
|
+
const failedReview = publicFailedReview(attempt?.preliminaryReview, verdictMaxBytes);
|
|
229
250
|
const cleanup = attempt?.cleanup
|
|
230
251
|
.filter((step) => step.status !== "completed")
|
|
231
252
|
.map((step) => ({
|
|
@@ -254,14 +275,14 @@ function publicTaskRecovery(task: RunState["tasks"][number]) {
|
|
|
254
275
|
};
|
|
255
276
|
}
|
|
256
277
|
|
|
257
|
-
function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
|
|
278
|
+
function publicNeedsAttention(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
|
|
258
279
|
if (state.status !== "needs_attention") return undefined;
|
|
259
280
|
const task = state.tasks.find((candidate) => candidate.status === "needs_attention" && candidate.taskId === preferredTaskId)
|
|
260
281
|
?? state.tasks.find((candidate) => candidate.status === "needs_attention");
|
|
261
|
-
if (task) return publicTaskRecovery(task);
|
|
282
|
+
if (task) return publicTaskRecovery(task, verdictMaxBytes);
|
|
262
283
|
if (state.final.failure || state.final.checks?.passed === false || state.final.review?.passed === false) {
|
|
263
284
|
const failedCheck = publicFailedCheck(state.final.checks);
|
|
264
|
-
const failedReview = publicFailedReview(state.final.review);
|
|
285
|
+
const failedReview = publicFailedReview(state.final.review, verdictMaxBytes);
|
|
265
286
|
return {
|
|
266
287
|
scope: "final" as const,
|
|
267
288
|
...(state.final.failure ? { failure: boundedPublicText(state.final.failure) } : {}),
|
|
@@ -275,8 +296,8 @@ function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
|
|
|
275
296
|
};
|
|
276
297
|
}
|
|
277
298
|
|
|
278
|
-
function publicState(state: RunState, preferredTaskId?: string) {
|
|
279
|
-
const needsAttention = publicNeedsAttention(state, preferredTaskId);
|
|
299
|
+
function publicState(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
|
|
300
|
+
const needsAttention = publicNeedsAttention(state, verdictMaxBytes, preferredTaskId);
|
|
280
301
|
return {
|
|
281
302
|
version: state.version,
|
|
282
303
|
id: state.request.id,
|
|
@@ -313,7 +334,7 @@ function publicState(state: RunState, preferredTaskId?: string) {
|
|
|
313
334
|
...(combinedTip ? { combinedTip } : {}),
|
|
314
335
|
...(correction ? { correction } : {}),
|
|
315
336
|
...(checks ? { checked: checks.passed, failedCheck: publicFailedCheck(checks) } : {}),
|
|
316
|
-
...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review) } : {}),
|
|
337
|
+
...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review, verdictMaxBytes) } : {}),
|
|
317
338
|
...(promotion ? { promotion: promotion.status, ...(promotion.mainAfter ? { mainAfter: promotion.mainAfter } : {}),
|
|
318
339
|
...(promotion.failure ? { promotionFailure: boundedPublicText(promotion.failure) } : {}) } : {}),
|
|
319
340
|
...(failure ? { failure: boundedPublicText(failure) } : {}),
|
|
@@ -329,6 +350,42 @@ function publicState(state: RunState, preferredTaskId?: string) {
|
|
|
329
350
|
};
|
|
330
351
|
}
|
|
331
352
|
|
|
353
|
+
/** Model-visible failed review verdicts from every task, the final gate, and each generation, within one shared budget.
|
|
354
|
+
* Pi shows the model `content`, not `structuredContent`; task reviews appear even while sibling tasks still run. */
|
|
355
|
+
function failedReviewText(state: RunState): string {
|
|
356
|
+
const reviews = [
|
|
357
|
+
...state.tasks.flatMap((task) => {
|
|
358
|
+
const review = task.kind === "changeset" ? task.attempts.at(-1)?.preliminaryReview : undefined;
|
|
359
|
+
return review && !review.passed ? [{ label: `Task ${task.taskId}`, review }] : [];
|
|
360
|
+
}),
|
|
361
|
+
...(state.final.review && !state.final.review.passed ? [{ label: "Final", review: state.final.review }] : []),
|
|
362
|
+
...state.integration.generations.flatMap(({ number, review }) => review && !review.passed ? [{ label: `Integration generation ${number}`, review }] : []),
|
|
363
|
+
].reduce<{ label: string; review: ReviewEvidence }[]>((merged, entry) => {
|
|
364
|
+
// Show an identical verdict once, but keep every scope that failed with it.
|
|
365
|
+
const same = merged.find(({ review }) => review.verdict === entry.review.verdict);
|
|
366
|
+
if (same) same.label = `${same.label}, ${entry.label}`;
|
|
367
|
+
else merged.push({ ...entry });
|
|
368
|
+
return merged;
|
|
369
|
+
}, []);
|
|
370
|
+
let budget = PUBLIC_REVIEW_VERDICT_MAX_BYTES;
|
|
371
|
+
let omitted = 0;
|
|
372
|
+
const parts: string[] = [];
|
|
373
|
+
for (const { label, review } of reviews) {
|
|
374
|
+
if (budget <= 0) { omitted++; continue; }
|
|
375
|
+
const verdict = boundedPublicText(review.verdict, budget);
|
|
376
|
+
budget -= Buffer.byteLength(verdict, "utf8");
|
|
377
|
+
parts.push(`\n\n${label}: failed ${review.phase} review verdict${verdict !== review.verdict ? " (truncated)" : ""}:\n${workerOutputForMain(verdict)}`);
|
|
378
|
+
}
|
|
379
|
+
if (omitted) parts.push(`\n\n${omitted} more failed review verdict(s) omitted from this text; structuredContent lists each one.`);
|
|
380
|
+
return parts.join("");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function hasTruncatedVerdict(state: ReturnType<typeof publicState>): boolean {
|
|
384
|
+
const attention = state.needsAttention;
|
|
385
|
+
return Boolean(attention && "failedReview" in attention && attention.failedReview?.verdictTruncated)
|
|
386
|
+
|| state.integration.generations.some((generation) => generation.failedReview?.verdictTruncated);
|
|
387
|
+
}
|
|
388
|
+
|
|
332
389
|
function inspectionNotices({ state, main, continuation }: RunResponse): string[] {
|
|
333
390
|
const lines = [
|
|
334
391
|
`Isolated request ${state.request.id}: ${state.status}; accepted: ${state.accepted}; final: ${state.final.status}; Main: ${main?.status ?? "not checked"}.`,
|
|
@@ -338,7 +395,7 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
|
|
|
338
395
|
? `Main inspection unavailable: ${boundedPublicText(main.failure)}; expected: ${JSON.stringify(main.expected)}`
|
|
339
396
|
: `Main ${main.status}: ${JSON.stringify({ expected: main.expected, actual: main.actual })}`);
|
|
340
397
|
if (state.integration.refresh) lines.push(`Integration refresh: ${state.integration.refresh.status}${state.integration.refresh.failure ? `; blockage: ${boundedPublicText(state.integration.refresh.failure)}` : ""}.`);
|
|
341
|
-
const attention = publicNeedsAttention(state);
|
|
398
|
+
const attention = publicNeedsAttention(state, PUBLIC_EVIDENCE_MAX_BYTES);
|
|
342
399
|
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 })}`);
|
|
343
400
|
if (state.final.failure) lines.push(`Final blockage: ${boundedPublicText(state.final.failure)}`);
|
|
344
401
|
for (const task of state.tasks) {
|
|
@@ -404,7 +461,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
404
461
|
return {
|
|
405
462
|
content: [{ type: "text" as const, text }],
|
|
406
463
|
details: {
|
|
407
|
-
state: publicState(response.state, preferredTaskId),
|
|
464
|
+
state: publicState(response.state, PUBLIC_REVIEW_VERDICT_MAX_BYTES, preferredTaskId),
|
|
408
465
|
...(response.main ? { main: response.main } : {}),
|
|
409
466
|
...(response.continuation ? { continuation: response.continuation } : {}),
|
|
410
467
|
},
|
|
@@ -415,6 +472,8 @@ export interface IsolatedSurface {
|
|
|
415
472
|
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
416
473
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
417
474
|
recover(cwd: string): Promise<string>;
|
|
475
|
+
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
476
|
+
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
|
|
418
477
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
419
478
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
420
479
|
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
@@ -487,6 +546,10 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
487
546
|
pi.on("model_select", (event, ctx) => { latestCtx = { ...ctx, model: event.model } as ExtensionContext; });
|
|
488
547
|
pi.on("agent_settled", (_event, ctx) => { latestCtx = ctx; });
|
|
489
548
|
|
|
549
|
+
const pruneGuards = (root: string): PruneGuards => ({
|
|
550
|
+
ownership: async (checkouts) => await getComponents().runner.pruneOwnership(checkouts),
|
|
551
|
+
panes: async () => await listHerdrPanes(root),
|
|
552
|
+
});
|
|
490
553
|
const requireCurrent = (current: () => boolean) => {
|
|
491
554
|
if (!current() || sessionClosed) throw new Error("Session or branch changed; reopen /subagent.");
|
|
492
555
|
};
|
|
@@ -551,14 +614,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
551
614
|
}
|
|
552
615
|
const state = response?.state ?? latestState;
|
|
553
616
|
try {
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
617
|
+
const preview = state ? publicState(state, PUBLIC_EVIDENCE_MAX_BYTES, response?.continuation && "taskId" in response.continuation
|
|
618
|
+
? response.continuation.taskId : undefined) : undefined;
|
|
619
|
+
const details = preview ? {
|
|
620
|
+
state: preview,
|
|
557
621
|
...(response?.continuation ? { continuation: response.continuation } : {}),
|
|
558
622
|
} : { id };
|
|
559
623
|
pi.sendMessage({
|
|
560
624
|
customType: "pi-subagent-isolated-result",
|
|
561
|
-
content:
|
|
625
|
+
content: preview && hasTruncatedVerdict(preview)
|
|
626
|
+
? `${text}\n\nReview verdicts here are previews; run subagent_status for the full verdict.`
|
|
627
|
+
: text,
|
|
562
628
|
display: true,
|
|
563
629
|
details,
|
|
564
630
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
@@ -597,7 +663,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
597
663
|
reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
|
|
598
664
|
return;
|
|
599
665
|
}
|
|
600
|
-
deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
|
|
666
|
+
deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state, PUBLIC_EVIDENCE_MAX_BYTES))}`, response);
|
|
601
667
|
},
|
|
602
668
|
(error: unknown) => {
|
|
603
669
|
finish();
|
|
@@ -628,15 +694,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
628
694
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
629
695
|
latestCtx = ctx;
|
|
630
696
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
631
|
-
const
|
|
697
|
+
const response = await getComponents().runner.status(params.id, root, signal);
|
|
698
|
+
const result = toolResult(response, ctx, workspaceRowsByRequest);
|
|
699
|
+
const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
|
|
632
700
|
// The projection is plain JSON; its conditional fields are typed optional rather than absent.
|
|
633
|
-
return { ...result, structuredContent: result.details as JsonValue };
|
|
701
|
+
return { ...result, content, structuredContent: result.details as JsonValue };
|
|
634
702
|
},
|
|
635
703
|
});
|
|
636
704
|
pi.registerTool({
|
|
637
705
|
name: "subagent_resume",
|
|
638
706
|
label: "Subagent resume",
|
|
639
|
-
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
|
|
707
|
+
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count. For a retry that sends a same-worker correction, optional instruction (at most 32000 characters) is appended to that prompt as Main's instruction after the saved check or review failure; use it to name the exact fixes, including the review findings the worker must address. Other actions reject instruction.",
|
|
640
708
|
parameters: ResumeRequestParameters,
|
|
641
709
|
prepareArguments: parseResumeRequest,
|
|
642
710
|
exposure: "model-only",
|
|
@@ -748,17 +816,26 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
748
816
|
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
749
817
|
: "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
|
|
750
818
|
];
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
819
|
+
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
820
|
+
},
|
|
821
|
+
async prunePlan(cwd) {
|
|
822
|
+
const root = await lookupRoot(cwd);
|
|
823
|
+
return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
|
|
824
|
+
},
|
|
825
|
+
async prune(cwd, items, allowIgnored, current) {
|
|
826
|
+
requireCurrent(current);
|
|
827
|
+
const root = await lookupRoot(cwd);
|
|
828
|
+
const { removals, checkouts } = await removePruneCandidates(root, cwd, items, allowIgnored,
|
|
829
|
+
{ ...pruneGuards(root), current: () => current() && !sessionClosed });
|
|
830
|
+
const removed = removals.filter(({ outcome }) => outcome === "removed");
|
|
831
|
+
const reconciled = removed.length
|
|
832
|
+
? await getComponents().runner.reconcilePrunedAborts(checkouts, removed.map(({ item }) => basename(item.branch))) : [];
|
|
833
|
+
const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
|
|
834
|
+
return boundedReport([
|
|
835
|
+
`Pi Subagent prune in ${root}: ${removed.length} removed, ${count("skipped")} skipped, ${count("failed")} failed.`,
|
|
836
|
+
...removals.map(({ outcome, item, reason }) => `${outcome}: ${item.path ?? "(no checkout)"} · ${item.branch} · ${reason}`),
|
|
837
|
+
...reconciled,
|
|
838
|
+
], "prune report lines omitted.");
|
|
762
839
|
},
|
|
763
840
|
async inspect(root, requestId) {
|
|
764
841
|
const response = await getComponents().runner.status(requestId, root);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capEphemeralSubagentOutput } from "@henryqw/pi-subagent";
|
|
1
|
+
import { capEphemeralSubagentOutput, formatDuration } from "@henryqw/pi-subagent";
|
|
2
2
|
import type { WorkflowEntry, WorkflowMode } from "./workflow.ts";
|
|
3
3
|
|
|
4
4
|
const EVIDENCE_PREVIEW_CODE_POINTS = 256;
|
|
@@ -22,6 +22,9 @@ export type WorkflowTransportEntry = {
|
|
|
22
22
|
role: WorkflowEntry["delegation"]["role"];
|
|
23
23
|
model?: string;
|
|
24
24
|
thinkingLevel?: string;
|
|
25
|
+
/** Measured session tokens; absent when usage was unavailable. */
|
|
26
|
+
tokens?: number;
|
|
27
|
+
elapsedMs?: number;
|
|
25
28
|
status: WorkflowTransportStatus;
|
|
26
29
|
assistantOutput?: string;
|
|
27
30
|
failure?: string;
|
|
@@ -76,6 +79,21 @@ export function displaySummary(text: string): string {
|
|
|
76
79
|
return Array.from(normalized).slice(0, 160).join("");
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
export function compactTokens(tokens: number): string {
|
|
83
|
+
return `${tokens < 1_000 ? tokens : `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`} tok`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Launched workers carry a model; workers that never launched show no usage. */
|
|
87
|
+
function usageFor(entry: WorkflowTransportEntry): string {
|
|
88
|
+
if (entry.model === undefined) return "";
|
|
89
|
+
return [
|
|
90
|
+
entry.model,
|
|
91
|
+
...(entry.thinkingLevel === undefined ? [] : [`thinking ${entry.thinkingLevel}`]),
|
|
92
|
+
entry.tokens === undefined ? "tokens unknown" : compactTokens(entry.tokens),
|
|
93
|
+
...(entry.elapsedMs === undefined ? [] : [formatDuration(entry.elapsedMs)]),
|
|
94
|
+
].map((part) => ` · ${part}`).join("");
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
export type WorkflowEntryStatusPresentation = { glyph: string; fallback: string };
|
|
80
98
|
|
|
81
99
|
export const ENTRY_STATUS_PRESENTATION = {
|
|
@@ -120,7 +138,7 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
|
|
|
120
138
|
...positioned.map(({ entry, position }) => {
|
|
121
139
|
const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[entry.status];
|
|
122
140
|
const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
|
|
123
|
-
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
|
|
141
|
+
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role}${usageFor(entry)} — ${summary}`;
|
|
124
142
|
}),
|
|
125
143
|
...(evidence.length ? [
|
|
126
144
|
"Results:",
|
|
@@ -146,6 +164,8 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
|
|
|
146
164
|
...(source === undefined ? {} : { summary: displaySummary(source) }),
|
|
147
165
|
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
148
166
|
...(entry.thinkingLevel === undefined ? {} : { thinkingLevel: entry.thinkingLevel }),
|
|
167
|
+
...(entry.tokens === undefined ? {} : { tokens: entry.tokens }),
|
|
168
|
+
...(entry.elapsedMs === undefined ? {} : { elapsedMs: entry.elapsedMs }),
|
|
149
169
|
};
|
|
150
170
|
}),
|
|
151
171
|
},
|