@hcflabs/pi-subagent 27.10.8 → 27.11.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 +16 -1
- package/docs/orchestration.md +2 -0
- package/extensions/admission.ts +57 -18
- package/extensions/repos.ts +40 -0
- package/extensions/subagent.ts +53 -19
- package/extensions/workflow.ts +26 -3
- package/package.json +2 -1
package/CONTEXT.md
CHANGED
|
@@ -29,7 +29,7 @@ A display and continuation reference for one recorded direct worker, such as `D7
|
|
|
29
29
|
A new direct worker that resumes a finished worker's saved Pi conversation in a new tab with a new Direct reference.
|
|
30
30
|
|
|
31
31
|
**Checkout admission**:
|
|
32
|
-
The exclusive permission held by a direct write request that prevents Main and other direct writers from writing to the shared checkout. It remains held until worker termination is proven; read classification is not a sandbox.
|
|
32
|
+
The exclusive permission held by a direct write request that prevents Main and other direct writers from writing to the shared checkout. A request with Repository targets holds the checkout of each target. From a non-Git parent directory, a Main call that can write covers the checkout of every Repository target in it. It remains held until worker termination is proven; read classification is not a sandbox.
|
|
33
33
|
|
|
34
34
|
**Isolated request**:
|
|
35
35
|
A durable goal and dependency graph of text and changeset tasks, with checked results and recovery state.
|
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ Commands, prompts, and UI are for you. Tools and the packaged skill are for Main
|
|
|
35
35
|
| `/review [target] [focus]` | prompt | Tell Main to review a change with parallel `reviewer` tasks and give one ship verdict. See [Review a change](#review-a-change). |
|
|
36
36
|
| `/subagent recover` | command | Classify interrupted isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
|
|
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
|
+
| `subagent_repos` | tool | List the child Git repositories below the Pi working directory and the `repo` selector of each. Read-only. |
|
|
38
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`. |
|
|
39
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
41
|
| `subagent_stage` | tool | Stage, resolve, reject, or revise an exact candidate. Rejecting a staged candidate freezes its generation. |
|
|
@@ -95,7 +96,21 @@ A task packet above 64 KiB uses a private file, including the full previous answ
|
|
|
95
96
|
|
|
96
97
|
Pi's `read` can retrieve lines of at most 50 KiB (51,200 bytes), with line offsets for more content. If any line is longer, the worker uses `bash`, even when the Role also has `read`. Without `bash`, that task fails before tab creation; use shorter lines or a Role with `bash`. A Role with neither tool also fails before tab creation. Chain context is never cut to the inline answer budget.
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
### Repository targets
|
|
100
|
+
|
|
101
|
+
When Pi runs in a parent directory that holds many repositories, set `repo` to select a child repository for a direct task. Use `subagent_repos` to list the selectors. Set `repo` on the request to apply it to all tasks, or on one task to override the request value:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{ "mode": "direct", "repo": "team/service", "role": "scout", "name": "Map sign-in", "task": "Trace sign-in.", "access": "read" }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`repo` is a path relative to the Pi working directory. It must be a Git top-level directory, must not contain `..`, and must not resolve outside that directory. A subdirectory of a repository fails. No tool selects a child repository automatically.
|
|
108
|
+
|
|
109
|
+
The worker opens in the current Herdr workspace with the Repository target as its working directory. Change reports and stale-read checks use that checkout, and each result names its `repo`. A write request reserves the checkout of each target at one time, so writers in different repositories can run together. When Pi runs in a parent directory that is not a Git repository, each Main call that can write covers the whole parent. Such a call waits while a direct writer owns the checkout of any Repository target in it. Main can still read files and use `subagent_repos`.
|
|
110
|
+
|
|
111
|
+
Isolated mode does not accept `repo` yet.
|
|
112
|
+
|
|
113
|
+
To continue a finished worker, add `"continueFrom": "D7.1"` with the same `role`. This opens a new tab with the saved conversation and a new reference. Continue the newest reference from the original checkout and workspace. For a worker that ran with `repo`, select the same `repo`. For eligibility and stop behavior, see [Continue or stop one direct worker](./docs/orchestration.md#continue-or-stop-one-direct-worker).
|
|
99
114
|
|
|
100
115
|
### Review a change
|
|
101
116
|
|
package/docs/orchestration.md
CHANGED
|
@@ -93,6 +93,8 @@ For example, a configured Git operator with `bash` can stage named pending files
|
|
|
93
93
|
|
|
94
94
|
Direct writes change Main's checkout immediately. They require no clean checkout. They give no automatic checks, rollback, or isolated integration guarantee. A write request cannot start while a direct writer owns the checkout. It also cannot start while a Main call that can write is in progress there.
|
|
95
95
|
|
|
96
|
+
A task with `repo` runs in that Repository target instead of Main's checkout. Admission resolves the target before the launch and reserves the checkout of each target at one time. Two checkouts conflict when one contains the other. Thus a Main call from a non-Git parent directory covers the checkout of every Repository target in it, and writers in two different Repository targets can run together. An invalid `repo` blocks the call before any reservation. Admission does not parse Bash text for a directory.
|
|
97
|
+
|
|
96
98
|
A write request runs its tasks in sequence. It holds checkout admission after the initial handle returns. Competing direct writers and Main writes fail until the extension proves that all owned workers stopped. To keep Main free to write, use `mode: isolated`. This mode gives worktree checks and explicit integration, not immediate changes to the shared checkout. Parallel read requests still run concurrently.
|
|
97
99
|
|
|
98
100
|
A failure in a write request stops further dispatch, but a worker with an uncertain state can still run. Chains replace each literal `{previous}` with the preceding successful answer and stop on failure. Task outcome and worker termination are separate.
|
package/extensions/admission.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { isWithin, resolveRepoTarget } from "@hcflabs/pi-repo";
|
|
3
4
|
|
|
4
5
|
// Main's own tools that never change a checkout; every other Main call may write.
|
|
5
6
|
// `codemode` mutates nothing itself: its nested tool calls are admitted by their own names.
|
|
6
7
|
const MAIN_NON_WRITING_TOOLS = new Set([
|
|
7
|
-
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status", "codemode",
|
|
8
|
+
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status", "subagent_repos", "codemode",
|
|
8
9
|
]);
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Classification of a `delegate_task` call. `false` means the call does not write.
|
|
13
|
+
* `true` means a writer in the Pi working directory. A list names the Repository target of each task.
|
|
14
|
+
* `undefined` in the list means the Pi working directory.
|
|
15
|
+
*/
|
|
16
|
+
export type DirectAdmission = boolean | ReadonlyArray<string | undefined>;
|
|
17
|
+
|
|
10
18
|
async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
|
|
11
19
|
const cwd = await realpath(ctx.cwd);
|
|
12
20
|
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5_000 });
|
|
@@ -16,15 +24,23 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
16
24
|
return await realpath(root);
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
/** Two scopes conflict when one contains the other. A non-Git parent directory covers its whole subtree. */
|
|
28
|
+
const overlaps = (left: string, right: string) => isWithin(left, right) || isWithin(right, left);
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* Reserve checkouts only for direct writers. Main writes can run together, but
|
|
21
|
-
* each call must finish before a direct writer can start in
|
|
32
|
+
* each call must finish before a direct writer can start in an overlapping checkout.
|
|
33
|
+
* A direct writer reserves the checkout of each task target at one time.
|
|
22
34
|
* External processes and trusted extension lifecycle code are not intercepted.
|
|
23
35
|
*/
|
|
24
|
-
export
|
|
36
|
+
export type CheckoutAdmission = ((toolCallId: string) => () => void) & {
|
|
37
|
+
/** The real checkout paths that admission reserved for a direct writer call. */
|
|
38
|
+
admitted(toolCallId: string): readonly string[] | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function registerCheckoutAdmission(pi: ExtensionAPI, classifyDirect: (input: unknown) => DirectAdmission): CheckoutAdmission {
|
|
25
42
|
const heldCalls = new Set<string>();
|
|
26
|
-
const
|
|
27
|
-
const calls = new Map<string, { direct: boolean; key?: string }>();
|
|
43
|
+
const calls = new Map<string, { direct: boolean; keys?: readonly string[] }>();
|
|
28
44
|
// One Git lookup per working directory per session; a failed lookup is retried.
|
|
29
45
|
const checkouts = new Map<string, Promise<string>>();
|
|
30
46
|
const lookup = (ctx: ExtensionContext) => {
|
|
@@ -35,32 +51,49 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
|
|
|
35
51
|
key.catch(() => { if (checkouts.get(ctx.cwd) === key) checkouts.delete(ctx.cwd); });
|
|
36
52
|
return key;
|
|
37
53
|
};
|
|
54
|
+
const targetKey = async (ctx: ExtensionContext, repo: string | undefined) => {
|
|
55
|
+
if (repo === undefined) return await lookup(ctx);
|
|
56
|
+
const target = await resolveRepoTarget(ctx.cwd, repo, {
|
|
57
|
+
runProcess: (command, args, options) => pi.exec(command, args, { cwd: options.cwd, timeout: options.timeoutMs ?? undefined, ...(options.signal ? { signal: options.signal } : {}) }),
|
|
58
|
+
});
|
|
59
|
+
return target.root;
|
|
60
|
+
};
|
|
38
61
|
const release = (toolCallId: string) => {
|
|
39
62
|
if (heldCalls.has(toolCallId)) return;
|
|
40
|
-
const call = calls.get(toolCallId);
|
|
41
63
|
calls.delete(toolCallId);
|
|
42
|
-
if (call?.key && ownerByCheckout.get(call.key) === toolCallId) ownerByCheckout.delete(call.key);
|
|
43
64
|
};
|
|
44
65
|
pi.on("tool_call", async (event, ctx) => {
|
|
45
66
|
const direct = event.toolName === "delegate_task";
|
|
46
|
-
|
|
47
|
-
|
|
67
|
+
const classified = direct ? classifyDirect(event.input) : !MAIN_NON_WRITING_TOOLS.has(event.toolName);
|
|
68
|
+
if (classified === false) return;
|
|
69
|
+
const repos: ReadonlyArray<string | undefined> = classified === true ? [undefined] : classified;
|
|
70
|
+
const call: { direct: boolean; keys?: readonly string[] } = { direct };
|
|
48
71
|
calls.set(event.toolCallId, call);
|
|
49
|
-
|
|
72
|
+
let keys: string[];
|
|
73
|
+
try {
|
|
74
|
+
keys = [...new Set(await Promise.all(repos.map((repo) => targetKey(ctx, repo))))];
|
|
75
|
+
} catch (error) {
|
|
76
|
+
release(event.toolCallId);
|
|
77
|
+
if (!direct || repos.every((repo) => repo === undefined)) throw error;
|
|
78
|
+
return { block: true, reason: `Repository target check failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
79
|
+
}
|
|
50
80
|
if (calls.get(event.toolCallId) !== call) {
|
|
51
81
|
return { block: true, reason: `Call ${event.toolCallId} already settled; use mode: isolated to keep Main free.` };
|
|
52
82
|
}
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
83
|
+
// A direct writer conflicts with every overlapping call. A Main call conflicts only with a direct writer.
|
|
84
|
+
const conflict = [...calls].find(([id, active]) => id !== event.toolCallId && active.keys
|
|
85
|
+
&& (direct || active.direct)
|
|
86
|
+
&& active.keys.some((held) => keys.some((key) => overlaps(held, key))));
|
|
87
|
+
if (conflict) {
|
|
88
|
+
const [owner, active] = conflict;
|
|
89
|
+
const key = active.keys!.find((held) => keys.some((candidate) => overlaps(held, candidate)))!;
|
|
56
90
|
release(event.toolCallId);
|
|
57
91
|
return {
|
|
58
92
|
block: true,
|
|
59
|
-
reason: `Checkout ${key} has ${
|
|
93
|
+
reason: `Checkout ${key} has ${active.direct ? `an admitted direct writer (${owner})` : `a Main potentially-writing call in flight (${owner})`}; retry after it stops, or use mode: isolated to keep Main free.`,
|
|
60
94
|
};
|
|
61
95
|
}
|
|
62
|
-
call.
|
|
63
|
-
if (direct) ownerByCheckout.set(key, event.toolCallId);
|
|
96
|
+
call.keys = keys;
|
|
64
97
|
});
|
|
65
98
|
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
66
99
|
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
@@ -71,12 +104,18 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
|
|
|
71
104
|
for (const toolCallId of calls.keys()) release(toolCallId);
|
|
72
105
|
});
|
|
73
106
|
// The tool returns a handle before its worker stops. Release only after verified termination.
|
|
74
|
-
|
|
107
|
+
const hold = (toolCallId: string) => {
|
|
75
108
|
const call = calls.get(toolCallId);
|
|
76
|
-
if (!call?.direct || !call.
|
|
109
|
+
if (!call?.direct || !call.keys) {
|
|
77
110
|
throw new Error(`Direct call ${toolCallId} was not admitted as a checkout writer; delegate again.`);
|
|
78
111
|
}
|
|
79
112
|
heldCalls.add(toolCallId);
|
|
80
113
|
return () => { heldCalls.delete(toolCallId); release(toolCallId); };
|
|
81
114
|
};
|
|
115
|
+
return Object.assign(hold, {
|
|
116
|
+
admitted: (toolCallId: string) => {
|
|
117
|
+
const call = calls.get(toolCallId);
|
|
118
|
+
return call?.direct ? call.keys : undefined;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
82
121
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { discoverRepos } from "@hcflabs/pi-repo";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
const OUTPUT_REPOS = 200;
|
|
6
|
+
|
|
7
|
+
const Parameters = Type.Object({
|
|
8
|
+
path: Type.Optional(Type.String({ minLength: 1, description: "Directory to search, relative to the Pi working directory. The default is the Pi working directory." })),
|
|
9
|
+
maxDepth: Type.Optional(Type.Integer({ minimum: 1, maximum: 8, description: "Directory levels to search below path. The default is 3." })),
|
|
10
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: OUTPUT_REPOS, description: `Maximum repositories to return. The default is ${OUTPUT_REPOS}.` })),
|
|
11
|
+
}, { additionalProperties: false });
|
|
12
|
+
|
|
13
|
+
/** Register the read-only `subagent_repos` tool. It lists child repositories and the `repo` selector for each. */
|
|
14
|
+
export function registerRepoDiscovery(pi: ExtensionAPI): void {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "subagent_repos",
|
|
17
|
+
label: "Subagent Repos",
|
|
18
|
+
description: "List the child Git repositories below the Pi working directory, with the repo selector that delegate_task accepts. The tool changes no files and no Git state. It does not follow symlinks and does not search inside a repository.",
|
|
19
|
+
promptSnippet: "List child Git repositories and their repo selectors for delegate_task",
|
|
20
|
+
promptGuidelines: [
|
|
21
|
+
"When the Pi working directory holds several repositories, use subagent_repos to find the repo selector, then set repo on delegate_task. Never guess a selector, and never select a repository the user did not name or authorize.",
|
|
22
|
+
],
|
|
23
|
+
parameters: Parameters,
|
|
24
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
25
|
+
const found = await discoverRepos(ctx.cwd, {
|
|
26
|
+
...(params.path === undefined ? {} : { path: params.path }),
|
|
27
|
+
...(params.maxDepth === undefined ? {} : { maxDepth: params.maxDepth }),
|
|
28
|
+
limit: params.limit ?? OUTPUT_REPOS,
|
|
29
|
+
...(signal ? { signal } : {}),
|
|
30
|
+
});
|
|
31
|
+
const lines = [
|
|
32
|
+
`${found.repos.length} repositories below ${found.base}${found.truncated ? " (truncated: a limit stopped the search)" : ""}.`,
|
|
33
|
+
...found.repos.map(({ repo, linkedWorktree }) => `${repo}${linkedWorktree ? " (linked worktree)" : ""}`),
|
|
34
|
+
...(found.issues.length ? ["Issues:", ...found.issues.slice(0, 20).map(({ path, reason }) => `${path}: ${reason}`)] : []),
|
|
35
|
+
...(found.issues.length > 20 ? [`${found.issues.length - 20} more issues omitted.`] : []),
|
|
36
|
+
];
|
|
37
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }], details: found };
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -3,14 +3,16 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
|
|
|
3
3
|
import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { availableTaskModels, loadTaskModelsConfig, modelReference, type ProfileName, registerModelTask, registerTaskModelsConfigReporting, resolveAvailableModel, resolveConfiguredTaskRoute, type ResolvedTaskRoute, taskThinkingLevels } from "@hcflabs/pi-task-models";
|
|
5
5
|
import { registerConfigReporting } from "@hcflabs/pi-config-store";
|
|
6
|
+
import { resolveRepoTarget } from "@hcflabs/pi-repo";
|
|
6
7
|
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, createSubagentConfigStore, DEFAULT_SUBAGENT_CONFIG, DELEGATE_TASK, findRole, formatDuration, loadRoleCatalog, prepareRoleLaunch, resolveRolePackageResources, resolveTaskRoute, ROLE_TOOL_POLICY_FLAG, type ExecutionPolicySnapshot, type Role } from "@hcflabs/pi-subagent";
|
|
7
8
|
import { resolveExecutionPolicy } from "./config.ts";
|
|
8
9
|
import { registerCheckoutAdmission } from "./admission.ts";
|
|
9
10
|
import { registerIsolatedExtension } from "./isolated.ts";
|
|
11
|
+
import { registerRepoDiscovery } from "./repos.ts";
|
|
10
12
|
import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask } from "./subagent-command.ts";
|
|
11
13
|
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
12
14
|
import { compactCost, compactTokens, ENTRY_STATUS_PRESENTATION, formatWorkflowResult, workerOutputForMain, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
|
|
13
|
-
import { checkoutNote, DelegateTaskParameters, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, snapshotCheckout, writesCheckout, type CheckoutSnapshot, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
|
|
15
|
+
import { checkoutNote, DelegateTaskParameters, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, snapshotCheckout, workflowRepos, writesCheckout, type CheckoutSnapshot, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
|
|
14
16
|
import { assertSettledDirectSession, createDirectHerdr, DirectAllocationError, directTaskReader, type DirectAllocation, type DirectHandle, type DirectTab, type DirectToolActivity, type DirectUsage } from "../dist/direct-herdr.js";
|
|
15
17
|
import { materializeTransientLaunch } from "../dist/launch-runtime.js";
|
|
16
18
|
const WIDGET_KEY = "subagent-status";
|
|
@@ -223,15 +225,17 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
223
225
|
reportConfig(ctx);
|
|
224
226
|
currentPolicy();
|
|
225
227
|
};
|
|
226
|
-
|
|
228
|
+
/** A direct writer reserves the checkout of each task target. An unparsable call is a writer in the Pi working directory. */
|
|
229
|
+
const classifyDirect = (input: unknown): boolean | Array<string | undefined> => {
|
|
227
230
|
try {
|
|
228
231
|
const parsed = parseDelegateTask(input);
|
|
229
|
-
return parsed.mode === "direct" && !parsed.dryRun && writesCheckout(parsed.workflow);
|
|
232
|
+
return parsed.mode === "direct" && !parsed.dryRun && writesCheckout(parsed.workflow) && workflowRepos(parsed.workflow);
|
|
230
233
|
} catch {
|
|
231
234
|
return true;
|
|
232
235
|
}
|
|
233
236
|
};
|
|
234
|
-
const holdAdmission = registerCheckoutAdmission(pi,
|
|
237
|
+
const holdAdmission = registerCheckoutAdmission(pi, classifyDirect);
|
|
238
|
+
registerRepoDiscovery(pi);
|
|
235
239
|
/** Resolve a Role's package resources and route for a direct Herdr launch. */
|
|
236
240
|
const prepareDirectLaunch = async (role: Role, context: ExtensionContext, options: { modelClass?: ProfileName; model?: string }) => {
|
|
237
241
|
const resources = await resolveRolePackageResources(role, context);
|
|
@@ -634,7 +638,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
634
638
|
};
|
|
635
639
|
|
|
636
640
|
/** Dry run of a direct launch: the same checks, then a bounded plan; no tab, record, admission, or follow-up. */
|
|
637
|
-
const planDirect = async (workflow: ParsedWorkflow, sources: ReadonlyMap<number, DirectTabRecord & { turn: string }>, cwd: string, ctx: ExtensionContext) => {
|
|
641
|
+
const planDirect = async (workflow: ParsedWorkflow, sources: ReadonlyMap<number, DirectTabRecord & { turn: string }>, cwd: string, targets: readonly string[], ctx: ExtensionContext) => {
|
|
638
642
|
for (const [index, source] of sources) {
|
|
639
643
|
try { await assertSettledDirectSession(source.sessionFile, source.turn); } catch (error) {
|
|
640
644
|
throw boundedError(new Error(`Cannot continue ${workflow.delegations[index]!.continueFrom}: ${error instanceof Error ? error.message : String(error)}.`, { cause: error }));
|
|
@@ -652,6 +656,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
652
656
|
directTaskReader(workflow.mode === "chain" ? delegation.task.replaceAll("{previous}", "") : delegation.task, prepared.tools);
|
|
653
657
|
} catch (error) { throw boundedError(error); }
|
|
654
658
|
entries.push({ index, name, role, access, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel,
|
|
659
|
+
...(delegation.repo === undefined ? {} : { repo: delegation.repo, cwd: targets[index]! }),
|
|
655
660
|
...(continueFrom === undefined ? {} : { continueFrom }), ...(workflow.mode === "chain" && index ? { after: index } : {}) });
|
|
656
661
|
}
|
|
657
662
|
const writes = writesCheckout(workflow);
|
|
@@ -663,8 +668,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
663
668
|
];
|
|
664
669
|
const text = [
|
|
665
670
|
`Dry run passed: direct ${workflow.mode} request with ${entries.length} task(s) in ${cwd}. No Herdr tab, session record, checkout admission, or follow-up was created.`,
|
|
666
|
-
...entries.map((entry) => `${entry.index + 1}. ${entry.name} · ${entry.role} · ${entry.model} · thinking ${entry.thinkingLevel} · access ${entry.access}${entry.access === "write" ? " (JSON completion required)" : ""}${entry.after ? ` · after ${entry.after}` : ""}${entry.continueFrom ? ` · continues ${entry.continueFrom}` : ""}`),
|
|
667
|
-
writes ?
|
|
671
|
+
...entries.map((entry) => `${entry.index + 1}. ${entry.name} · ${entry.role} · ${entry.model} · thinking ${entry.thinkingLevel} · access ${entry.access}${entry.access === "write" ? " (JSON completion required)" : ""}${entry.repo ? ` · repo ${entry.repo} (${entry.cwd})` : ""}${entry.after ? ` · after ${entry.after}` : ""}${entry.continueFrom ? ` · continues ${entry.continueFrom}` : ""}`),
|
|
672
|
+
writes ? `Write request: tasks would run serially and reserve ${new Set(targets).size > 1 ? "each target checkout" : "this checkout"} until owned workers are proved stopped.`
|
|
668
673
|
: "Read request: tasks would run concurrently without checkout admission.",
|
|
669
674
|
...notChecked.map((reason) => `Not checked: ${reason}.`),
|
|
670
675
|
].join("\n");
|
|
@@ -720,7 +725,25 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
720
725
|
const branchTabs = ctx.sessionManager.getBranch().flatMap((entry) =>
|
|
721
726
|
entry.type === "custom" && entry.customType === DIRECT_TAB_TYPE && entry.data ? [entry.data as DirectTabRecord] : []);
|
|
722
727
|
const policy = currentPolicy();
|
|
723
|
-
|
|
728
|
+
// Resolve each Repository target before continuation checks, admission evidence, or Herdr calls.
|
|
729
|
+
const targets: string[] = [];
|
|
730
|
+
for (const [index, { repo }] of workflow.delegations.entries()) {
|
|
731
|
+
if (repo === undefined) { targets.push(ctx.cwd); continue; }
|
|
732
|
+
try {
|
|
733
|
+
targets.push((await resolveRepoTarget(ctx.cwd, repo, signal ? { signal } : {})).root);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
throw boundedError(new Error(`Task ${index + 1} repo ${repo}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Workers open in the caller's Herdr workspace with the target as their working directory.
|
|
739
|
+
const herdrByCwd = new Map<string, ReturnType<typeof createDirectHerdr>>();
|
|
740
|
+
const herdrFor = (cwd: string) => {
|
|
741
|
+
let client = herdrByCwd.get(cwd);
|
|
742
|
+
if (!client) herdrByCwd.set(cwd, client = createDirectHerdr(pi, cwd, policy.childIdleMs));
|
|
743
|
+
return client;
|
|
744
|
+
};
|
|
745
|
+
const herdr = herdrFor(ctx.cwd);
|
|
746
|
+
const entryCwd = (index: number) => herdrFor(targets[index]!).cwd;
|
|
724
747
|
const sources = new Map<number, DirectTabRecord & { turn: string }>();
|
|
725
748
|
for (const [index, { continueFrom, role }] of workflow.delegations.entries()) {
|
|
726
749
|
if (continueFrom === undefined) continue;
|
|
@@ -731,27 +754,28 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
731
754
|
const source = matches[0]!;
|
|
732
755
|
if (source.display!.role !== role) throw refuse(`it ran with Role ${source.display!.role}, not ${role}; use the recorded Role`);
|
|
733
756
|
if (source.turn === undefined) throw refuse("its record has no turn identity (saved by an older pi-subagent), so it is not continuable; start a fresh task");
|
|
734
|
-
|
|
735
|
-
|
|
757
|
+
const target = herdrFor(targets[index]!);
|
|
758
|
+
if (source.workspaceId !== target.workspaceId || source.cwd !== target.cwd) {
|
|
759
|
+
throw refuse(`it ran in ${source.cwd ?? "an unrecorded checkout"} (Herdr workspace ${source.workspaceId ?? "unrecorded"}), not in ${target.cwd} and this workspace; select the repo where it ran, or start a fresh task`);
|
|
736
760
|
}
|
|
737
761
|
if ([...directTasks.values()].some(({ tabs, continues }) => continues?.has(source.sessionFile)
|
|
738
762
|
|| tabs.some(({ sessionFile }) => sessionFile === source.sessionFile))) throw refuse("a locally active direct task still owns its session");
|
|
739
763
|
if ([...sources.values()].some(({ sessionFile }) => sessionFile === source.sessionFile)) throw refuse("this request already continues its session");
|
|
740
764
|
sources.set(index, { ...source, turn: source.turn });
|
|
741
765
|
}
|
|
742
|
-
if (parsed.dryRun) return await planDirect(workflow, sources, herdr.cwd, latestCtx ?? ctx);
|
|
766
|
+
if (parsed.dryRun) return await planDirect(workflow, sources, herdr.cwd, workflow.delegations.map((_, index) => entryCwd(index)), latestCtx ?? ctx);
|
|
743
767
|
const writes = writesCheckout(workflow);
|
|
744
768
|
const entries = identifyWorkflowEntries(toolCallId, workflow);
|
|
745
769
|
const states = new Map<string, WorkflowTransportEntry>(entries.map(({ id, index, delegation }) => [id, {
|
|
746
770
|
id, index, role: delegation.role, status: "pending",
|
|
747
|
-
name: delegation.continueFrom === undefined ? delegation.name : `${delegation.name} (continued from ${delegation.continueFrom})`,
|
|
771
|
+
name: `${delegation.continueFrom === undefined ? delegation.name : `${delegation.name} (continued from ${delegation.continueFrom})`}${delegation.repo === undefined ? "" : ` · repo ${delegation.repo}`}`,
|
|
748
772
|
}]));
|
|
749
773
|
const startedAt = new Map<string, number>();
|
|
750
774
|
// Launch snapshots: write requests report changes; read requests detect a stale checkout.
|
|
751
775
|
const snapshots = new Map<string, CheckoutSnapshot | Error | undefined>();
|
|
752
|
-
const checkout = async ({ id, delegation }: WorkflowEntry) => {
|
|
776
|
+
const checkout = async ({ id, index, delegation }: WorkflowEntry) => {
|
|
753
777
|
if (!snapshots.has(id)) return {};
|
|
754
|
-
const note = await checkoutNote(
|
|
778
|
+
const note = await checkoutNote(entryCwd(index), snapshots.get(id), writes, delegation.paths);
|
|
755
779
|
return note ? { checkout: note } : {};
|
|
756
780
|
};
|
|
757
781
|
const measured = (id: string, usage: DirectUsage | undefined) => ({
|
|
@@ -780,6 +804,16 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
780
804
|
const settled = new Promise<void>((resolve) => { resolveSettled = resolve; });
|
|
781
805
|
// Register before Herdr can create a tab: session switches must await this
|
|
782
806
|
// launch, including its identity callback, before copying recovery records.
|
|
807
|
+
if (writes) {
|
|
808
|
+
// A selector can point somewhere else after admission resolved it, for example through a symlink.
|
|
809
|
+
// Launch only in the exact checkouts that admission reserved.
|
|
810
|
+
const admitted = holdAdmission.admitted(toolCallId) ?? [];
|
|
811
|
+
for (const [index, { repo }] of workflow.delegations.entries()) {
|
|
812
|
+
if (repo !== undefined && !admitted.includes(targets[index]!)) {
|
|
813
|
+
throw boundedError(new Error(`Task ${index + 1} repo ${repo} resolves to ${targets[index]}, which admission did not reserve (${admitted.join(", ") || "none"}). The target changed after admission; delegate again.`));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
783
817
|
const releaseAdmission = writes ? holdAdmission(toolCallId) : () => {};
|
|
784
818
|
const local: NonNullable<ReturnType<typeof directTasks.get>> = { controller, settled, handles, tabs,
|
|
785
819
|
continues: new Set([...sources.values()].map(({ sessionFile }) => sessionFile)) };
|
|
@@ -795,7 +829,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
795
829
|
const proof = (async () => {
|
|
796
830
|
entryControllers.get(entryId)!.abort();
|
|
797
831
|
await handle.cancel().catch(() => undefined);
|
|
798
|
-
await
|
|
832
|
+
await herdrFor(tab.cwd ?? ctx.cwd).stop(tab, inScope);
|
|
799
833
|
})();
|
|
800
834
|
stopProofs.set(entryId, proof);
|
|
801
835
|
return proof;
|
|
@@ -805,7 +839,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
805
839
|
const inScope = () => current() && (local.closeScope?.() ?? true);
|
|
806
840
|
try {
|
|
807
841
|
if (local.allocation) throw new Error(local.allocation.failure);
|
|
808
|
-
for (const tab of tabs) await
|
|
842
|
+
for (const tab of tabs) await herdrFor(tab.cwd ?? ctx.cwd).stop(tab, inScope);
|
|
809
843
|
if (!inScope()) throw new Error("Direct task scope changed; reopen /subagent.");
|
|
810
844
|
releaseAdmission();
|
|
811
845
|
directTasks.delete(taskId);
|
|
@@ -831,10 +865,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
831
865
|
startWidgetItem(entry.id, taskId, display, prepared.model.id, prepared.thinkingLevel, ctx);
|
|
832
866
|
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
833
867
|
promptArgIndex: prepared.promptArgIndex }, activeSignal);
|
|
834
|
-
snapshots.set(entry.id, await snapshotCheckout(
|
|
868
|
+
snapshots.set(entry.id, await snapshotCheckout(entryCwd(entry.index)).catch((error: unknown) => error instanceof Error ? error : new Error(String(error))));
|
|
835
869
|
let handle: DirectHandle;
|
|
836
870
|
try {
|
|
837
|
-
handle = await
|
|
871
|
+
handle = await herdrFor(targets[entry.index]!).start({ ...prepared, args: [...transient.launch.args] },
|
|
838
872
|
`d-${randomUUID().replaceAll("-", "").slice(0, 24)}`, `${directRef(display)} ${display.title}`,
|
|
839
873
|
`${entry.delegation.access === "read" ? `${entry.delegation.task}\n\n${READ_ACCESS_NOTICE}` : entry.delegation.task}\n\nFinal answer budget: ${answerBytes} bytes, conclusions first. Main sees only that much inline; a longer answer is saved to a file.`, activeSignal, (tab) => {
|
|
840
874
|
const record = { taskId, entryId: entry.id, display, ...tab };
|
|
@@ -867,7 +901,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
867
901
|
try {
|
|
868
902
|
await assertSettledDirectSession(source.sessionFile, source.turn);
|
|
869
903
|
// Close only an idle or done exact agent; an absent one still needs two empty lease scans.
|
|
870
|
-
await
|
|
904
|
+
await herdrFor(targets[index]!).stop(source, () => launchEpoch === sessionEpoch && !controller.signal.aborted, true);
|
|
871
905
|
} catch (error) {
|
|
872
906
|
throw new Error(`Cannot continue ${workflow.delegations[index]!.continueFrom}: ${error instanceof Error ? error.message : String(error)}.`, { cause: error });
|
|
873
907
|
}
|
package/extensions/workflow.ts
CHANGED
|
@@ -33,6 +33,11 @@ const AccessSchema = StringEnum(DIRECT_ACCESS, { description: ACCESS_GUIDANCE })
|
|
|
33
33
|
const DryRunSchema = Type.Boolean({ description: "Optional. When true, run every launch validation and return the resolved plan without starting workers, saving state, or reserving the checkout." });
|
|
34
34
|
const ContinueFromSchema = Type.String({ pattern: "^D[1-9][0-9]*\\.[1-9][0-9]*$", description: "Optional direct worker reference such as D7.1. Continues that finished worker's Pi session in a new tab with this task; role must equal the recorded Role." });
|
|
35
35
|
|
|
36
|
+
const RepoSchema = Type.String({
|
|
37
|
+
minLength: 1,
|
|
38
|
+
description: "Optional Repository target: a child Git repository as a path relative to the Pi working directory, such as team/service. The worker runs there, and admission reserves that checkout. A task value overrides the request value. Omit it to use the Pi working directory. Use subagent_repos to list targets.",
|
|
39
|
+
});
|
|
40
|
+
|
|
36
41
|
const PathsSchema = Type.Array(Type.String({ minLength: 1 }), {
|
|
37
42
|
minItems: 1,
|
|
38
43
|
maxItems: 32,
|
|
@@ -46,6 +51,7 @@ export const DelegationSchema = Type.Object({
|
|
|
46
51
|
access: AccessSchema,
|
|
47
52
|
paths: Type.Optional(PathsSchema),
|
|
48
53
|
continueFrom: Type.Optional(ContinueFromSchema),
|
|
54
|
+
repo: Type.Optional(RepoSchema),
|
|
49
55
|
kind: Type.Optional(DirectKindSchema),
|
|
50
56
|
model: Type.Optional(ModelSchema),
|
|
51
57
|
modelClass: Type.Optional(ModelClassSchema),
|
|
@@ -59,6 +65,7 @@ export const DirectWorkflowSchema = Type.Object({
|
|
|
59
65
|
access: Type.Optional(AccessSchema),
|
|
60
66
|
paths: Type.Optional(PathsSchema),
|
|
61
67
|
continueFrom: Type.Optional(ContinueFromSchema),
|
|
68
|
+
repo: Type.Optional(RepoSchema),
|
|
62
69
|
kind: Type.Optional(DirectKindSchema),
|
|
63
70
|
model: Type.Optional(ModelSchema),
|
|
64
71
|
modelClass: Type.Optional(ModelClassSchema),
|
|
@@ -114,8 +121,18 @@ function normalizePaths(value: Static<typeof DelegationSchema>, path: string): s
|
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
/** Check the selector shape here. Admission and launch resolve it against the Pi working directory. */
|
|
125
|
+
function normalizeRepo(value: string, path: string): string {
|
|
126
|
+
const normalized = text(value, path);
|
|
127
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(normalized) || normalized.startsWith("/") || normalized.split(/[\\/]/).includes("..")) {
|
|
128
|
+
throw new Error(`${path} must be a relative path without control characters or .. components.`);
|
|
129
|
+
}
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeDelegation(value: Static<typeof DelegationSchema>, path: string, requestRepo?: string): Delegation {
|
|
118
134
|
const paths = normalizePaths(value, path);
|
|
135
|
+
const repo = value.repo === undefined ? requestRepo : normalizeRepo(value.repo, `${path}.repo`);
|
|
119
136
|
return {
|
|
120
137
|
role: text(value.role, `${path}.role`),
|
|
121
138
|
name: normalizeTaskName(value.name, `${path}.name`),
|
|
@@ -123,6 +140,7 @@ function normalizeDelegation(value: Static<typeof DelegationSchema>, path: strin
|
|
|
123
140
|
access: value.access,
|
|
124
141
|
...(paths === undefined ? {} : { paths }),
|
|
125
142
|
...(value.continueFrom === undefined ? {} : { continueFrom: value.continueFrom }),
|
|
143
|
+
...(repo === undefined ? {} : { repo }),
|
|
126
144
|
kind: "text",
|
|
127
145
|
...(value.model === undefined ? {} : { model: text(value.model, `${path}.model`) }),
|
|
128
146
|
...(value.modelClass === undefined ? {} : { modelClass: value.modelClass }),
|
|
@@ -164,9 +182,10 @@ export function parseWorkflow(value: unknown): ParsedWorkflow {
|
|
|
164
182
|
if (!mode) throw new Error("direct workflow must select exactly one of single, tasks, or chain.");
|
|
165
183
|
const input = value as DirectInput;
|
|
166
184
|
if (mode === "single" && !hasDelegation(input)) throw new Error("direct workflow requires role, name, task, and access.");
|
|
185
|
+
const repo = input.repo === undefined ? undefined : normalizeRepo(input.repo, "workflow.repo");
|
|
167
186
|
const workflow: ParsedWorkflow = mode === "single" ? { mode, delegations: [normalizeDelegation(input as Static<typeof DelegationSchema>, "workflow")] }
|
|
168
|
-
: mode === "parallel" ? { mode, delegations: input.tasks!.map((item, index) => normalizeDelegation(item, `tasks[${index}]
|
|
169
|
-
: { mode, delegations: input.chain!.map((item, index) => normalizeDelegation(item, `chain[${index}]
|
|
187
|
+
: mode === "parallel" ? { mode, delegations: input.tasks!.map((item, index) => normalizeDelegation(item, `tasks[${index}]`, repo)) }
|
|
188
|
+
: { mode, delegations: input.chain!.map((item, index) => normalizeDelegation(item, `chain[${index}]`, repo)) };
|
|
170
189
|
const continued = workflow.delegations.flatMap(({ continueFrom }) => continueFrom === undefined ? [] : [continueFrom]);
|
|
171
190
|
if (new Set(continued).size !== continued.length) throw new Error("A direct request may continue each worker at most once.");
|
|
172
191
|
return workflow;
|
|
@@ -175,6 +194,9 @@ export function parseWorkflow(value: unknown): ParsedWorkflow {
|
|
|
175
194
|
/** A direct request is a writer request when any task declares write access. */
|
|
176
195
|
export const writesCheckout = (workflow: ParsedWorkflow): boolean => workflow.delegations.some(({ access }) => access === "write");
|
|
177
196
|
|
|
197
|
+
/** The Repository target of each task. `undefined` means the Pi working directory. */
|
|
198
|
+
export const workflowRepos = (workflow: ParsedWorkflow): Array<string | undefined> => workflow.delegations.map(({ repo }) => repo);
|
|
199
|
+
|
|
178
200
|
/** `dryRun` is a tool-call option, not part of the direct workflow or the durable isolated request. */
|
|
179
201
|
export function parseDelegateTask(value: unknown): ParsedDelegateTask {
|
|
180
202
|
let dryRun = false;
|
|
@@ -185,6 +207,7 @@ export function parseDelegateTask(value: unknown): ParsedDelegateTask {
|
|
|
185
207
|
value = rest;
|
|
186
208
|
}
|
|
187
209
|
if (value && typeof value === "object" && !Array.isArray(value) && (value as { mode?: unknown }).mode === "isolated") {
|
|
210
|
+
if (Object.hasOwn(value, "repo")) throw new Error("mode isolated does not accept repo yet. Start Pi in the repository, or use mode direct.");
|
|
188
211
|
return { mode: "isolated", request: parseExecuteRequest(value), dryRun };
|
|
189
212
|
}
|
|
190
213
|
return { mode: "direct", workflow: parseWorkflow(value), dryRun };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hcflabs/pi-subagent",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.11.0",
|
|
4
4
|
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"@hcflabs/pi-herdr": "^0.5.0",
|
|
75
75
|
"@hcflabs/pi-multi-codex": "^3.0.0",
|
|
76
76
|
"@hcflabs/pi-process": "^0.2.0",
|
|
77
|
+
"@hcflabs/pi-repo": "^0.1.0",
|
|
77
78
|
"@hcflabs/pi-task-models": "^9.0.0",
|
|
78
79
|
"proper-lockfile": "^4.1.2"
|
|
79
80
|
},
|