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