@hcflabs/pi-subagent 28.2.3 → 28.3.1
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/README.md +2 -2
- package/dist/herdr-runtime.d.ts +14 -0
- package/dist/herdr-runtime.js +73 -19
- package/dist/index.js +0 -3
- package/dist/runner.d.ts +21 -0
- package/dist/runner.js +89 -1
- package/docs/orchestration.md +5 -1
- package/extensions/subagent.ts +13 -8
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -37,13 +37,13 @@ Commands, prompts, and UI are for you. Tools and the packaged skill are for Main
|
|
|
37
37
|
| `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. With `dryRun: true`, check the request and show the resolved plan without a launch. |
|
|
38
38
|
| `subagent_repos` | tool | List the child Git repositories below the Pi working directory and the `repo` selector of each. Read-only. |
|
|
39
39
|
| `subagent_status` | tool | Read saved isolated state, exact identities, failed review verdicts, and allowed continuation. Does not replay work. Supports structured output for `codemode`. |
|
|
40
|
-
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation, not staging or integration. Same-worker correction retries accept optional `instruction` text. |
|
|
40
|
+
| `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation, not staging or integration. Same-worker correction retries accept optional `instruction` text. `verify` can also adopt a later clean committed candidate from the same settled worker. That candidate must pass its normal checks and judgment. |
|
|
41
41
|
| `subagent_stage` | tool | Stage, resolve, reject, or revise an exact candidate. Rejecting a staged candidate freezes its generation. |
|
|
42
42
|
| `subagent_integrate` | tool | Advance dependents, refresh, validate, correct, promote, reconcile, clean up promotion, or release rejected and superseded resources. |
|
|
43
43
|
| `subagent_abort` | tool | Terminate an isolated request without discarding retained work. Retained candidates and integration work require release or proof of absence. |
|
|
44
44
|
| `git_read` | tool | Let Role-declared children inspect bounded local Git evidence without Bash, tests, network access, or writes. Not available to Main. |
|
|
45
45
|
| `pi-subagent` | skill | Guide Main through authorization, delegation, checks, integration, and recovery. |
|
|
46
|
-
| Direct status widget (`D`) | ui | Show direct workflows as a tree under `Main` with references, names, Role badges, status, routes, elapsed time, measured task tokens, cost, and tool activity. In fullscreen mode, a click on a working row focuses that worker's verified Herdr pane. |
|
|
46
|
+
| Direct status widget (`D`) | ui | Show direct workflows as a tree under `Main` with references, names, Role badges, status, routes, elapsed time, measured task tokens, cost, and tool activity. In fullscreen mode, a click on a working or finished worker row focuses that worker's verified Herdr pane. |
|
|
47
47
|
| Isolated status widget (`I`) | ui | Show recorded task state, Role/model-class badges, and retained workspaces in the TUI. Readiness does not mean promotion. |
|
|
48
48
|
|
|
49
49
|
`delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. Main must make these decisions visibly; `codemode` scripts can call only `subagent_status`.
|
package/dist/herdr-runtime.d.ts
CHANGED
|
@@ -104,6 +104,20 @@ export declare class HerdrHostRuntime implements HostRuntime {
|
|
|
104
104
|
outcome: "unknown";
|
|
105
105
|
failure: string;
|
|
106
106
|
}>;
|
|
107
|
+
/**
|
|
108
|
+
* Proves that the exact owned worker is settled with a `stop` Final turn on its latest recorded prompt.
|
|
109
|
+
* The method reads the lifecycle and the session one time. It sends no prompt and closes nothing.
|
|
110
|
+
*/
|
|
111
|
+
inspectSettledWorker(input: {
|
|
112
|
+
task: TaskRequest;
|
|
113
|
+
attempt: TaskAttempt;
|
|
114
|
+
workerId: string;
|
|
115
|
+
}, context: OperationContext): Promise<{
|
|
116
|
+
outcome: "settled";
|
|
117
|
+
} | {
|
|
118
|
+
outcome: "active" | "unknown";
|
|
119
|
+
failure: string;
|
|
120
|
+
}>;
|
|
107
121
|
private proveAbsentAgentForCleanup;
|
|
108
122
|
cleanupHost(input: {
|
|
109
123
|
requestId: ExecuteRequest["id"];
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -23,6 +23,8 @@ const STALLED_PROMPT_POLL_MS = 250;
|
|
|
23
23
|
const DEFAULT_SETTLED_IDLE_MS = 600_000;
|
|
24
24
|
const HOST_LAYOUT_POLL_MS = 50;
|
|
25
25
|
const HOST_LAYOUT_MAX_POLLS = 40;
|
|
26
|
+
const SHELL_IDLE_POLL_MS = 100;
|
|
27
|
+
const SHELL_IDLE_MAX_POLLS = 100;
|
|
26
28
|
const DIAGNOSTIC_LIMIT = 8 * 1024;
|
|
27
29
|
const LEASE_MODE = 0o600;
|
|
28
30
|
const DIRECTORY_MODE = 0o700;
|
|
@@ -663,6 +665,41 @@ export class HerdrHostRuntime {
|
|
|
663
665
|
return { outcome: "unknown", failure: safeText(error) };
|
|
664
666
|
}
|
|
665
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* Proves that the exact owned worker is settled with a `stop` Final turn on its latest recorded prompt.
|
|
670
|
+
* The method reads the lifecycle and the session one time. It sends no prompt and closes nothing.
|
|
671
|
+
*/
|
|
672
|
+
async inspectSettledWorker(input, context) {
|
|
673
|
+
try {
|
|
674
|
+
const allocation = ownedIntent(input.attempt, "agent");
|
|
675
|
+
requireIntentIdentity(allocation, input.attempt);
|
|
676
|
+
assertAgentIntent(allocation, input.attempt);
|
|
677
|
+
if (input.workerId !== allocation.agentName)
|
|
678
|
+
throw new Error("Settled worker inspection does not target the exact saved agent.");
|
|
679
|
+
this.assertLeasePath(allocation.leasePath, allocation.token);
|
|
680
|
+
await assertPrivateLease(allocation.leasePath, false);
|
|
681
|
+
// runWorker adds the prompt count after it records the prompt. So the latest prompt has the identity of the count.
|
|
682
|
+
const index = input.attempt.prompts.length;
|
|
683
|
+
if (!index)
|
|
684
|
+
throw new Error("Settled worker inspection requires a recorded worker prompt.");
|
|
685
|
+
const lifecycle = await this.waitForAgentLifecycle(allocation, context, true);
|
|
686
|
+
if (!SETTLED_AGENT_STATES.has(lifecycle.status)) {
|
|
687
|
+
return { outcome: "active", failure: `The exact worker is not settled: ${lifecycle.status}.` };
|
|
688
|
+
}
|
|
689
|
+
const suffix = `\n\nTurn identity: ${input.attempt.correlationToken}:${index}`;
|
|
690
|
+
const turn = await this.workerSessionTurn(allocation, (text) => text.endsWith(suffix), context.signal);
|
|
691
|
+
if (turn.state === "succeeded")
|
|
692
|
+
return { outcome: "settled" };
|
|
693
|
+
if (turn.state === "failed")
|
|
694
|
+
return { outcome: "unknown", failure: `The latest worker prompt has no stop Final turn. ${turn.failure}` };
|
|
695
|
+
return { outcome: "active", failure: turn.state === "running"
|
|
696
|
+
? "The latest worker prompt has no Final turn yet."
|
|
697
|
+
: "The worker session has no entry for the latest worker prompt." };
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
return { outcome: "unknown", failure: safeText(error) };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
666
703
|
async proveAbsentAgentForCleanup(allocation, attempt, context) {
|
|
667
704
|
requireIntentIdentity(allocation, attempt);
|
|
668
705
|
assertAgentIntent(allocation, attempt);
|
|
@@ -851,7 +888,7 @@ export class HerdrHostRuntime {
|
|
|
851
888
|
if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
|
|
852
889
|
throw new Error("Agent start requires an empty exact process lease.");
|
|
853
890
|
}
|
|
854
|
-
await this.assertStartableAgentPane(allocation, context, { requireExclusiveTty: false });
|
|
891
|
+
await this.assertStartableAgentPane(allocation, context, { requireExclusiveTty: false, awaitIdleShell: true });
|
|
855
892
|
const options = this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS);
|
|
856
893
|
const handle = await acquireLaunch();
|
|
857
894
|
return await withTransientLaunch(handle, async (launch) => {
|
|
@@ -1055,24 +1092,41 @@ export class HerdrHostRuntime {
|
|
|
1055
1092
|
|| (pane.agent !== undefined && pane.agent !== null) || pane.agent_status !== "unknown") {
|
|
1056
1093
|
throw new Error("The exact saved agent pane is not empty and startable in its owned worktree.");
|
|
1057
1094
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1095
|
+
let shellPid;
|
|
1096
|
+
for (let poll = 0;; poll += 1) {
|
|
1097
|
+
const processResponse = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
|
|
1098
|
+
const processResult = resultRecord(processResponse, "Herdr agent pane process-info response");
|
|
1099
|
+
if (processResult.type !== "pane_process_info")
|
|
1100
|
+
throw new Error("Herdr agent pane process-info response has the wrong type.");
|
|
1101
|
+
const processInfo = record(processResult.process_info, "Herdr agent pane process-info");
|
|
1102
|
+
if (exactString(processInfo.pane_id, "Herdr agent process pane ID") !== allocation.paneId) {
|
|
1103
|
+
throw new Error("The exact saved agent pane process state is not an idle foreground shell.");
|
|
1104
|
+
}
|
|
1105
|
+
shellPid = processInfo.shell_pid;
|
|
1106
|
+
const foreground = processInfo.foreground_processes;
|
|
1107
|
+
const idleGroup = Number.isSafeInteger(shellPid) && Number(shellPid) > 0
|
|
1108
|
+
&& processInfo.foreground_process_group_id === shellPid
|
|
1109
|
+
&& Array.isArray(foreground) && foreground.length === 1;
|
|
1110
|
+
const shell = idleGroup ? record(foreground[0], "Herdr agent pane foreground process") : undefined;
|
|
1111
|
+
if (shell && shell.pid === shellPid && shell.cwd === allocation.worktreeCwd) {
|
|
1112
|
+
exactString(shell.name, "Herdr agent pane shell name");
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
if (!options.awaitIdleShell) {
|
|
1116
|
+
if (!idleGroup)
|
|
1117
|
+
throw new Error("The exact saved agent pane process state is not an idle foreground shell.");
|
|
1118
|
+
throw new Error("The exact saved agent pane foreground process is not its owned idle shell.");
|
|
1119
|
+
}
|
|
1120
|
+
if (poll + 1 >= SHELL_IDLE_MAX_POLLS) {
|
|
1121
|
+
const processes = Array.isArray(foreground)
|
|
1122
|
+
? foreground.slice(0, 20).map((process) => `${process?.pid}:${process?.name}`).join(", ")
|
|
1123
|
+
: "malformed";
|
|
1124
|
+
const diagnostic = safeText(`shell_pid=${shellPid}, foreground_process_group_id=${processInfo.foreground_process_group_id}, foreground=[${processes}]`);
|
|
1125
|
+
throw new Error(`The exact saved agent pane process state is not an idle foreground shell. ${diagnostic}`);
|
|
1126
|
+
}
|
|
1127
|
+
// Only fresh allocation waits for shell startup. Recovery requires an immediate safety proof.
|
|
1128
|
+
await this.delay(SHELL_IDLE_POLL_MS, context.signal);
|
|
1129
|
+
}
|
|
1076
1130
|
if (!options.requireExclusiveTty)
|
|
1077
1131
|
return;
|
|
1078
1132
|
// Herdr's foreground list excludes background jobs, so recovery inventories every process on the shell's controlling TTY.
|
package/dist/index.js
CHANGED
|
@@ -12,8 +12,6 @@ export { loadRoleMcpConfig, parseRoleMcpAllowlist } from "./mcp-role.js";
|
|
|
12
12
|
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
|
|
13
13
|
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, } from "./worktree.js";
|
|
14
14
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
|
|
15
|
-
const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
|
|
16
|
-
const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@hcflabs/pi-multi-codex/extensions/multi-codex.ts"));
|
|
17
15
|
const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", import.meta.url));
|
|
18
16
|
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
19
17
|
const GIT_READ_EXTENSION = fileURLToPath(new URL("../extensions/git-read.ts", import.meta.url));
|
|
@@ -510,7 +508,6 @@ function prepareRoleLaunchFromSkills(ctx, input, skills) {
|
|
|
510
508
|
}
|
|
511
509
|
const extensions = [
|
|
512
510
|
...selectedExtensions,
|
|
513
|
-
...(CODEX_ALIAS.test(input.route.model.provider) ? [MULTI_CODEX_EXTENSION] : []),
|
|
514
511
|
// Always loaded: it connects the servers that Role extensions register; `mcps` gates only mcp.json.
|
|
515
512
|
ROLE_MCP_EXTENSION,
|
|
516
513
|
...(role.tools.includes("git_read") ? [GIT_READ_EXTENSION] : []),
|
package/dist/runner.d.ts
CHANGED
|
@@ -193,6 +193,20 @@ export interface HostRuntime {
|
|
|
193
193
|
outcome: "unknown";
|
|
194
194
|
failure: string;
|
|
195
195
|
}>;
|
|
196
|
+
/**
|
|
197
|
+
* Optional. Proves that the exact owned worker is settled with a `stop` Final turn on its latest recorded prompt.
|
|
198
|
+
* Without this method, `verify` cannot adopt a later candidate.
|
|
199
|
+
*/
|
|
200
|
+
inspectSettledWorker?(input: {
|
|
201
|
+
task: TaskRequest;
|
|
202
|
+
attempt: TaskAttempt;
|
|
203
|
+
workerId: string;
|
|
204
|
+
}, context: OperationContext): Promise<{
|
|
205
|
+
outcome: "settled";
|
|
206
|
+
} | {
|
|
207
|
+
outcome: "active" | "unknown";
|
|
208
|
+
failure: string;
|
|
209
|
+
}>;
|
|
196
210
|
cleanupHost(input: {
|
|
197
211
|
requestId: ExecuteRequest["id"];
|
|
198
212
|
kind: HostCleanupKind;
|
|
@@ -437,6 +451,13 @@ export declare class IsolatedRunner {
|
|
|
437
451
|
private runCheckBatch;
|
|
438
452
|
private runReview;
|
|
439
453
|
private retainCandidate;
|
|
454
|
+
private lateAdoptionAvailable;
|
|
455
|
+
/**
|
|
456
|
+
* Adopts the later clean committed candidate of the same settled worker. It sends no prompt and uses no correction.
|
|
457
|
+
* A refusal before the candidate is saved throws and changes no state.
|
|
458
|
+
* Returns a response when the adopted candidate needs attention. Returns undefined when readiness can continue.
|
|
459
|
+
*/
|
|
460
|
+
private adoptLateCandidate;
|
|
440
461
|
private verifyRetainedTask;
|
|
441
462
|
private retry;
|
|
442
463
|
private finalize;
|
package/dist/runner.js
CHANGED
|
@@ -412,6 +412,22 @@ function correctionEligible(request, attempt) {
|
|
|
412
412
|
&& sameIdentity(attempt.preliminaryReview.tip, attempt.candidate)
|
|
413
413
|
&& sameIdentity(attempt.preliminaryReview.identityAfter, attempt.candidate)));
|
|
414
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* True when `verify` can adopt a later candidate of the same settled worker.
|
|
417
|
+
* The latest prompt settled without a candidate, and no candidate exists in the prompt lineage.
|
|
418
|
+
* The worker can still leave a candidate after a false finished reading.
|
|
419
|
+
*/
|
|
420
|
+
function lateAdoptionEligible(task, attempt) {
|
|
421
|
+
const last = attempt.prompts.at(-1);
|
|
422
|
+
return task.status === "needs_attention"
|
|
423
|
+
&& !attempt.superseded
|
|
424
|
+
&& !attempt.candidate && !attempt.candidateBase
|
|
425
|
+
&& last?.status === "settled" && !last.candidate
|
|
426
|
+
&& !attempt.prompts.some((prompt) => prompt.status === "ambiguous" || prompt.status === "submitting")
|
|
427
|
+
&& !attempt.termination && !attempt.readiness && !attempt.preliminaryChecks && !attempt.preliminaryReview
|
|
428
|
+
&& Boolean(allocationByKind(attempt, "agent")?.agentName && allocationByKind(attempt, "worktree")?.worktree)
|
|
429
|
+
&& last.preCandidate.head === attempt.waveBase.head;
|
|
430
|
+
}
|
|
415
431
|
function terminal(state) {
|
|
416
432
|
return TERMINAL_RUN_STATUSES.some((status) => status === state.status);
|
|
417
433
|
}
|
|
@@ -2925,11 +2941,83 @@ export class IsolatedRunner {
|
|
|
2925
2941
|
state.integration.candidates.sort((a, b) => state.tasks.findIndex((item) => item.taskId === a.taskId)
|
|
2926
2942
|
- state.tasks.findIndex((item) => item.taskId === b.taskId));
|
|
2927
2943
|
}
|
|
2944
|
+
lateAdoptionAvailable(state, task, attempt) {
|
|
2945
|
+
return Boolean(this.hostRuntime.inspectSettledWorker) && !state.integration.candidates.length
|
|
2946
|
+
&& lateAdoptionEligible(task, attempt);
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Adopts the later clean committed candidate of the same settled worker. It sends no prompt and uses no correction.
|
|
2950
|
+
* A refusal before the candidate is saved throws and changes no state.
|
|
2951
|
+
* Returns a response when the adopted candidate needs attention. Returns undefined when readiness can continue.
|
|
2952
|
+
*/
|
|
2953
|
+
async adoptLateCandidate(handle, task, attempt, scope) {
|
|
2954
|
+
const state = handle.state;
|
|
2955
|
+
const request = changesetTaskRequest(state, task.taskId);
|
|
2956
|
+
const last = attempt.prompts.at(-1);
|
|
2957
|
+
if (last.preCandidate.head !== attempt.waveBase.head) {
|
|
2958
|
+
throw new Error("Late candidate adoption requires a prompt lineage that starts at the wave base.");
|
|
2959
|
+
}
|
|
2960
|
+
const workerId = allocationByKind(attempt, "agent").agentName;
|
|
2961
|
+
const branch = `refs/heads/${allocationByKind(attempt, "worktree").worktree.branch}`;
|
|
2962
|
+
const requireSettled = async () => {
|
|
2963
|
+
const result = await this.callProductive(handle, scope, async (context) => await this.hostRuntime.inspectSettledWorker({
|
|
2964
|
+
task: request, attempt, workerId,
|
|
2965
|
+
}, context));
|
|
2966
|
+
if (result.outcome !== "settled") {
|
|
2967
|
+
throw new Error(`Late candidate adoption requires the exact worker to be settled with a stop Final turn on its latest prompt (${result.outcome}): ${boundedRuntimeText(result.failure, "Settled worker failure")}`);
|
|
2968
|
+
}
|
|
2969
|
+
};
|
|
2970
|
+
await requireSettled();
|
|
2971
|
+
const candidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectRetainedTask({
|
|
2972
|
+
root: state.root, task: request, attempt,
|
|
2973
|
+
}, context)), "Late task candidate identity");
|
|
2974
|
+
if (!isCleanCommitted(candidate) || candidate.branch !== branch || candidate.head === attempt.waveBase.head) {
|
|
2975
|
+
throw new Error("Late candidate adoption requires a new clean committed candidate on the owned branch.");
|
|
2976
|
+
}
|
|
2977
|
+
// A second proof after the Git read shows that the worker stayed settled on the same latest prompt.
|
|
2978
|
+
await requireSettled();
|
|
2979
|
+
last.candidate = candidate;
|
|
2980
|
+
attempt.candidate = candidate;
|
|
2981
|
+
attempt.candidateBase = attempt.waveBase;
|
|
2982
|
+
await this.saveProductive(handle);
|
|
2983
|
+
try {
|
|
2984
|
+
attempt.preliminaryChecks = await this.runCheckBatch(handle, request.checks, candidate, "preliminary", scope, task.taskId);
|
|
2985
|
+
await this.saveProductive(handle);
|
|
2986
|
+
if (!attempt.preliminaryChecks.passed) {
|
|
2987
|
+
this.attention(task, sameIdentity(attempt.preliminaryChecks.identityAfter, candidate)
|
|
2988
|
+
? "A declared task check failed on the adopted candidate."
|
|
2989
|
+
: "A declared task check changed the adopted candidate.");
|
|
2990
|
+
await this.saveProductive(handle);
|
|
2991
|
+
return this.response(state);
|
|
2992
|
+
}
|
|
2993
|
+
if (request.judgment) {
|
|
2994
|
+
attempt.preliminaryReview = await this.runReview(handle, request.judgment.criterion, attempt.candidateBase, candidate, request.judgment.role, request.judgment.modelClass, "preliminary", scope, task.taskId);
|
|
2995
|
+
await this.saveProductive(handle);
|
|
2996
|
+
if (!attempt.preliminaryReview.passed) {
|
|
2997
|
+
this.attention(task, "Preliminary Judgment did not return exact PASS on the adopted candidate.");
|
|
2998
|
+
await this.saveProductive(handle);
|
|
2999
|
+
return this.response(state);
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
catch (error) {
|
|
3004
|
+
this.rethrowStopped(error);
|
|
3005
|
+
this.attention(task, `Adopted candidate validation was interrupted: ${errorText(error)}`);
|
|
3006
|
+
await this.saveProductive(handle);
|
|
3007
|
+
throw error;
|
|
3008
|
+
}
|
|
3009
|
+
return undefined;
|
|
3010
|
+
}
|
|
2928
3011
|
async verifyRetainedTask(handle, task, scope) {
|
|
2929
3012
|
const attempt = latestAttempt(task);
|
|
2930
3013
|
if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
|
|
2931
3014
|
throw new Error("An ambiguous delivered prompt is retained for inspection or explicit abort and is never replayed.");
|
|
2932
3015
|
}
|
|
3016
|
+
if (!attempt.candidate && this.lateAdoptionAvailable(handle.state, task, attempt)) {
|
|
3017
|
+
const stopped = await this.adoptLateCandidate(handle, task, attempt, scope);
|
|
3018
|
+
if (stopped)
|
|
3019
|
+
return stopped;
|
|
3020
|
+
}
|
|
2933
3021
|
const candidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectRetainedTask({
|
|
2934
3022
|
root: handle.state.root, task: changesetTaskRequest(handle.state, task.taskId), attempt,
|
|
2935
3023
|
}, context)), "Retained task candidate identity");
|
|
@@ -3334,7 +3422,7 @@ export class IsolatedRunner {
|
|
|
3334
3422
|
else if (attempt && this.correctionAllowed(state, changesetTaskRequest(state, attention.taskId), attempt)) {
|
|
3335
3423
|
continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
|
|
3336
3424
|
}
|
|
3337
|
-
else if (hasEvidence) {
|
|
3425
|
+
else if (hasEvidence || (attempt && this.lateAdoptionAvailable(state, attention, attempt))) {
|
|
3338
3426
|
continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
|
|
3339
3427
|
}
|
|
3340
3428
|
}
|
package/docs/orchestration.md
CHANGED
|
@@ -200,7 +200,7 @@ Main · 2 working · 1 complete
|
|
|
200
200
|
└─ ✓ D8.1 [R] complete · Review patch · …
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
-
In Pi fullscreen mode, click a
|
|
203
|
+
In Pi fullscreen mode, click a worker row to focus its Herdr pane. This applies to working and finished workers, because a finished worker keeps its Herdr tab. A finished row stays in the widget until your next input. Before the extension moves focus, Herdr must confirm that the pane still runs the exact launched agent. If the check fails, for example because the tab was closed, Pi shows a warning and focus does not change. The header, group rows, the overflow row, and rows with a truncated reference do nothing when you click them. Regular mode leaves the mouse to the terminal. Use the Herdr tab label or `/subagent` to find a worker from the keyboard. A click moves focus only. It gives no ownership or control authority.
|
|
204
204
|
|
|
205
205
|
The isolated widget shows the recorded task state and any retained workspace. It does not show reliable live model, thinking, or token readings from Herdr. Attention shows before ordinary work, and overflow uses `+N more · /subagent`. An aborted request with visible tasks has its own `request aborted` row. This row does not change the recorded statuses of the tasks.
|
|
206
206
|
|
|
@@ -281,6 +281,8 @@ Every task declares `id`, `kind`, `role`, `modelClass`, `requirements`, `deliver
|
|
|
281
281
|
|
|
282
282
|
Admission and worker-wave dispatch pin Main's exact committed branch, HEAD, and tree without inspecting or modifying its staged, unstaged, or untracked edits. Worker allocation verifies the same committed identity around worktree creation, including resumed waves; staged-dependent waves verify their clean integration snapshot. A dirty Main can therefore produce a checked retained candidate, but branch/HEAD drift stops new waves. The runner executes ready worker tasks concurrently, inspects clean committed candidates, runs preliminary task checks and optional preliminary judgment, then records ready candidates for Main. **It does not automatically integrate them.**
|
|
283
283
|
|
|
284
|
+
The extension waits up to about 10 seconds for the shell of a new worker pane to finish startup before it starts Pi.
|
|
285
|
+
|
|
284
286
|
Main selects exact candidates through `subagent_stage` into an owned integration checkout in explicit order. Each stage is a committed merge of the candidate, not a mutation of Main. Conflicts retain the checkout for Main to resolve; `resolve` verifies the exact two-parent merge instead of replaying an uncertain merge. Main can explicitly run dependent tasks from an exact staged tip with `subagent_integrate advance` and stage their results afterward. Independent candidates may be staged without re-running workers.
|
|
285
287
|
|
|
286
288
|
Once all selected work is staged, Main calls `subagent_integrate validate` on the exact clean combined tip. It acknowledges the durably saved validation intent and continues in the session-owned background, independent of later turn cancellation; session replacement/shutdown still cancels it. A follow-up reports completion; status remains authoritative if delivery is missed. This runs every final check, including `pnpm test`, unless [exact check reuse](#validate-and-promote) applies, and optional final judgment in the integration checkout before any Main mutation. A failed check/review can be followed by one Main-authored committed correction in that checkout, recorded with `correct`, then another `validate`.
|
|
@@ -406,6 +408,8 @@ Parsing rejects unknown properties, malformed evidence, invalid lineage, and v4
|
|
|
406
408
|
|
|
407
409
|
Isolated workers save private exact Pi sessions under their owned process leases. A proved final answer without a changed clean commit requires attention. Missing or mismatched session proof cannot establish completion and never permits prompt replay.
|
|
408
410
|
|
|
411
|
+
A worker can leave a clean committed candidate after the runner records its turn as finished without one. `verify` can adopt that candidate after it proves that the same owned worker is settled with a `stop` Final turn on its latest prompt. The candidate then passes the task's normal preliminary checks and judgment before readiness. Adoption sends no prompt and uses no correction. It never replaces a saved candidate. While the same-worker correction is available, the reported continuation is `retry`.
|
|
412
|
+
|
|
409
413
|
When a finished turn leaves tracked or untracked changes in a valid owned worktree, the runner sends one commit reminder per dispatch try. It saves the reminder before delivery. This uses neither a correction nor a dispatch try. The runner never commits, stages, stashes, or discards. A resulting commit follows normal candidate checks; remaining dirty work requires attention.
|
|
410
414
|
|
|
411
415
|
`subagent_status` stays read-only and authoritative if delivery fails. Session replacement or shutdown suppresses stale follow-ups and logs the request ID and reason, not prompts or credentials. Synchronous delivery failures report without automatic replay. Pi reports asynchronous `send_message` failures through its runtime.
|
package/extensions/subagent.ts
CHANGED
|
@@ -117,14 +117,15 @@ function statusCounts(items: readonly WidgetItem[]): string {
|
|
|
117
117
|
return STATUS_COUNT_ORDER.flatMap((status) => counts[status] ? [`${counts[status]} ${statusLabel(status)}`] : []).join(" · ");
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** One rendered widget line. `focus` names a
|
|
120
|
+
/** One rendered widget line. `focus` names a worker item that a click may focus. */
|
|
121
121
|
type WidgetRow = { text: string; focus?: string };
|
|
122
122
|
type TreeLine = { top: number; child?: { last: boolean }; item?: [string, WidgetItem]; group?: WidgetItem[] };
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* Render the direct widget as a tree under a Main header.
|
|
126
126
|
* A workflow with one launched worker is a leaf row. A workflow with more workers gets a group row.
|
|
127
|
-
* `focusable` tells which item IDs have a
|
|
127
|
+
* `focusable` tells which item IDs have a launched handle. Working and finished worker rows are click targets.
|
|
128
|
+
* Only fully visible references become click targets.
|
|
128
129
|
*/
|
|
129
130
|
function renderWidgetRows(
|
|
130
131
|
items: ReadonlyArray<[string, WidgetItem]>,
|
|
@@ -168,7 +169,7 @@ function renderWidgetRows(
|
|
|
168
169
|
const text = truncateToWidth(`${branch}${renderWorkerRow(item, room, now, spinnerIndex, theme)}`, width);
|
|
169
170
|
// The glyph, a space, and the full reference must be visible before a click can name this worker.
|
|
170
171
|
const referenceVisible = visibleWidth(connector) + 2 + visibleWidth(item.ref) <= width;
|
|
171
|
-
rows.push({ text, ...(
|
|
172
|
+
rows.push({ text, ...(referenceVisible && focusable(id) ? { focus: id } : {}) });
|
|
172
173
|
}
|
|
173
174
|
if (hidden.length) rows.push({ text: truncateToWidth(`${theme.fg("dim", "└─ ")}${theme.fg("muted", `… ${hidden.length} more · ${statusCounts(hidden)}`)}`, width) });
|
|
174
175
|
return rows;
|
|
@@ -242,6 +243,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
242
243
|
});
|
|
243
244
|
const widgetItems = new Map<string, WidgetItem>();
|
|
244
245
|
const tokenHandles = new Map<string, DirectHandle>();
|
|
246
|
+
// A finished worker keeps its Herdr tab. Its row keeps a focus handle until the row leaves the widget.
|
|
247
|
+
const focusHandles = new Map<string, Pick<DirectHandle, "focus">>();
|
|
245
248
|
const samplingTokens = new Set<string>();
|
|
246
249
|
const configStore = createSubagentConfigStore();
|
|
247
250
|
const configReporter = registerConfigReporting(pi, [configStore]);
|
|
@@ -520,9 +523,9 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
520
523
|
let focusInFlight = false;
|
|
521
524
|
// A click is a request only. The handle checks the exact Herdr agent identity before it moves focus.
|
|
522
525
|
const focusWorker = (id: string) => {
|
|
523
|
-
const handle =
|
|
526
|
+
const handle = focusHandles.get(id);
|
|
524
527
|
const item = widgetItems.get(id);
|
|
525
|
-
if (!handle || item
|
|
528
|
+
if (!handle || !item || focusInFlight) return;
|
|
526
529
|
const epoch = sessionEpoch;
|
|
527
530
|
const ctx = latestCtx;
|
|
528
531
|
focusInFlight = true;
|
|
@@ -543,7 +546,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
543
546
|
return {
|
|
544
547
|
invalidate() {},
|
|
545
548
|
render(width) {
|
|
546
|
-
const rows = renderWidgetRows([...widgetItems], width, Date.now(), spinnerIndex, theme, (id) =>
|
|
549
|
+
const rows = renderWidgetRows([...widgetItems], width, Date.now(), spinnerIndex, theme, (id) => focusHandles.has(id));
|
|
547
550
|
renderedWidth = width;
|
|
548
551
|
hits = rows.flatMap(({ text, focus }, row) => focus ? [{ row, to: visibleWidth(text), id: focus }] : []);
|
|
549
552
|
return rows.map(({ text }) => text);
|
|
@@ -574,6 +577,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
574
577
|
for (const [oldestId, item] of widgetItems) {
|
|
575
578
|
if (item.status === "working" || item.taskId === taskId) continue;
|
|
576
579
|
widgetItems.delete(oldestId);
|
|
580
|
+
focusHandles.delete(oldestId);
|
|
577
581
|
if (widgetItems.size < MAX_WIDGET_ITEMS) break;
|
|
578
582
|
}
|
|
579
583
|
}
|
|
@@ -659,6 +663,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
659
663
|
stopWidgetTimer();
|
|
660
664
|
widgetItems.clear();
|
|
661
665
|
tokenHandles.clear();
|
|
666
|
+
focusHandles.clear();
|
|
662
667
|
samplingTokens.clear();
|
|
663
668
|
activeTui = undefined;
|
|
664
669
|
widgetInstalled = false;
|
|
@@ -679,7 +684,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
679
684
|
pi.on("input", (event) => {
|
|
680
685
|
if (event.source === "extension") return;
|
|
681
686
|
for (const [id, item] of widgetItems) {
|
|
682
|
-
if (item.status !== "working") widgetItems.delete(id);
|
|
687
|
+
if (item.status !== "working") { widgetItems.delete(id); focusHandles.delete(id); }
|
|
683
688
|
}
|
|
684
689
|
requestWidgetRender();
|
|
685
690
|
});
|
|
@@ -981,7 +986,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
981
986
|
await transient.cleanup();
|
|
982
987
|
handles.push(handle);
|
|
983
988
|
handleByEntry.set(entry.id, handle);
|
|
984
|
-
if (widgetItems.has(entry.id)) tokenHandles.set(entry.id, handle);
|
|
989
|
+
if (widgetItems.has(entry.id)) { tokenHandles.set(entry.id, handle); focusHandles.set(entry.id, handle); }
|
|
985
990
|
return handle;
|
|
986
991
|
};
|
|
987
992
|
// The first tab is verified before returning a handle; subsequent independent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hcflabs/pi-subagent",
|
|
3
|
-
"version": "28.
|
|
3
|
+
"version": "28.3.1",
|
|
4
4
|
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -72,7 +72,6 @@
|
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@hcflabs/pi-config-store": "^4.0.0",
|
|
74
74
|
"@hcflabs/pi-herdr": "^0.6.0",
|
|
75
|
-
"@hcflabs/pi-multi-codex": "^3.0.0",
|
|
76
75
|
"@hcflabs/pi-process": "^0.3.0",
|
|
77
76
|
"@hcflabs/pi-repo": "^0.1.0",
|
|
78
77
|
"@hcflabs/pi-task-models": "^9.0.0",
|