@hcflabs/pi-subagent 27.10.8 → 28.0.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 +38 -3
- package/dist/full-suite.d.ts +34 -0
- package/dist/full-suite.js +142 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +37 -0
- package/dist/runner.d.ts +6 -2
- package/dist/runner.js +27 -31
- package/dist/schema.d.ts +38 -3
- package/dist/schema.js +23 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.js +9 -7
- package/docs/orchestration.md +4 -2
- package/extensions/admission.ts +57 -18
- package/extensions/isolated.ts +14 -2
- package/extensions/repos.ts +40 -0
- package/extensions/subagent.ts +55 -19
- package/extensions/workflow.ts +26 -3
- package/package.json +2 -1
- package/skills/pi-subagent/SKILL.md +1 -1
package/dist/schema.js
CHANGED
|
@@ -2,7 +2,7 @@ import { isAbsolute } from "node:path";
|
|
|
2
2
|
import { parseRoleName } from "./index.js";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { Check, Errors } from "typebox/value";
|
|
5
|
-
export const RUN_STATE_VERSION =
|
|
5
|
+
export const RUN_STATE_VERSION = 6;
|
|
6
6
|
export const MAX_TASKS = 8;
|
|
7
7
|
export const MAX_EXECUTE_REQUEST_BYTES = 256 * 1024;
|
|
8
8
|
export const MAX_PERSISTED_RUNTIME_TEXT_BYTES = 8 * 1024;
|
|
@@ -26,6 +26,19 @@ export const CheckCommandSchema = Type.Object({
|
|
|
26
26
|
command: TextSchema,
|
|
27
27
|
args: Type.Array(Type.String({ maxLength: 16_000 }), { maxItems: 128 }),
|
|
28
28
|
}, { additionalProperties: false });
|
|
29
|
+
/** The Full suite fixed at admission, with its source. Validation checks the same source again. */
|
|
30
|
+
export const FullSuiteSchema = Type.Object({
|
|
31
|
+
check: CheckCommandSchema,
|
|
32
|
+
source: Type.Union([
|
|
33
|
+
Type.Object({ kind: Type.Literal("config") }, { additionalProperties: false }),
|
|
34
|
+
Type.Object({ kind: Type.Literal("package-json"), testScript: Type.String({ minLength: 1, maxLength: 16_000 }) }, { additionalProperties: false }),
|
|
35
|
+
Type.Object({
|
|
36
|
+
kind: Type.Literal("makefile"),
|
|
37
|
+
file: Type.Union([Type.Literal("GNUmakefile"), Type.Literal("makefile"), Type.Literal("Makefile")]),
|
|
38
|
+
sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
|
|
39
|
+
}, { additionalProperties: false }),
|
|
40
|
+
]),
|
|
41
|
+
}, { additionalProperties: false });
|
|
29
42
|
/** One display form for a check command; quoted args keep argv boundaries visible. */
|
|
30
43
|
export function formatCheckCommand({ command, args }) {
|
|
31
44
|
return [command, ...args.map((arg) => JSON.stringify(arg))].join(" ");
|
|
@@ -420,6 +433,8 @@ const RunStateSchema = Type.Object({
|
|
|
420
433
|
version: Type.Literal(RUN_STATE_VERSION),
|
|
421
434
|
request: ExecuteRequestSchema,
|
|
422
435
|
policy: ExecutionPolicySnapshotSchema,
|
|
436
|
+
/** Present exactly when the request has a changeset task. */
|
|
437
|
+
fullSuite: Type.Optional(FullSuiteSchema),
|
|
423
438
|
correctionCount: Type.Integer({ minimum: 0 }),
|
|
424
439
|
root: TextSchema,
|
|
425
440
|
requestStartMain: WorkspaceSchema,
|
|
@@ -1015,6 +1030,13 @@ export function parseRunState(value) {
|
|
|
1015
1030
|
const state = value;
|
|
1016
1031
|
const request = parseExecuteRequest(state.request);
|
|
1017
1032
|
parseIntegrationState(state.integration, request);
|
|
1033
|
+
const changeset = request.tasks.some((task) => task.kind === "changeset");
|
|
1034
|
+
if (changeset !== (state.fullSuite !== undefined))
|
|
1035
|
+
throw new Error("A Full suite must be recorded exactly for a request with changeset tasks.");
|
|
1036
|
+
if (state.fullSuite && !request.finalChecks.some(({ command, args }) => command === state.fullSuite.check.command
|
|
1037
|
+
&& args.length === state.fullSuite.check.args.length && args.every((arg, index) => arg === state.fullSuite.check.args[index]))) {
|
|
1038
|
+
throw new Error("The recorded Full suite is not in the request final checks.");
|
|
1039
|
+
}
|
|
1018
1040
|
if (state.status !== "aborted" && state.integration.generations.some((generation) => generation.supersededFrom === "discarded")) {
|
|
1019
1041
|
throw new Error("User-discarded generations require an aborted request.");
|
|
1020
1042
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export declare class FileRunStore {
|
|
|
53
53
|
invalidIds: string[];
|
|
54
54
|
}>;
|
|
55
55
|
private stateEntries;
|
|
56
|
-
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
56
|
+
/** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
|
|
57
57
|
assertLegacyAdmissionSafe(root: string): Promise<void>;
|
|
58
58
|
list(root: string): Promise<{
|
|
59
59
|
states: RunStateHandle[];
|
package/dist/store.js
CHANGED
|
@@ -18,8 +18,10 @@ const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
|
|
|
18
18
|
const STATE_MAX_BYTES = 128 * 1024 * 1024;
|
|
19
19
|
const ADMISSION_MAX_FILES = 256;
|
|
20
20
|
const ADMISSION_MAX_BYTES = STATE_MAX_BYTES;
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
// v4 and v5 use the same request statuses. Admission reads only the version and the status of older files.
|
|
22
|
+
const LEGACY_VERSIONS = new Set([4, 5]);
|
|
23
|
+
const LEGACY_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
|
|
24
|
+
const LEGACY_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
|
|
23
25
|
const LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 };
|
|
24
26
|
const LIFECYCLE_LOCK_OPTIONS = {
|
|
25
27
|
...LOCK_OPTIONS,
|
|
@@ -317,7 +319,7 @@ export class FileRunStore {
|
|
|
317
319
|
throw error;
|
|
318
320
|
}
|
|
319
321
|
}
|
|
320
|
-
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
322
|
+
/** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
|
|
321
323
|
async assertLegacyAdmissionSafe(root) {
|
|
322
324
|
const directory = this.stateDirectory(root);
|
|
323
325
|
await this.assertSafeDestination(root, directory);
|
|
@@ -348,12 +350,12 @@ export class FileRunStore {
|
|
|
348
350
|
}
|
|
349
351
|
if (value.version === RUN_STATE_VERSION)
|
|
350
352
|
continue;
|
|
351
|
-
if (value.version !==
|
|
352
|
-
|| (!
|
|
353
|
+
if (typeof value.version !== "number" || !LEGACY_VERSIONS.has(value.version) || !("status" in value) || typeof value.status !== "string"
|
|
354
|
+
|| (!LEGACY_TERMINAL_STATUSES.has(value.status) && !LEGACY_UNFINISHED_STATUSES.has(value.status))) {
|
|
353
355
|
throw new Error(`Pi Subagent malformed or unsupported legacy state ${id} blocks safe admission. ${recovery}`);
|
|
354
356
|
}
|
|
355
|
-
if (
|
|
356
|
-
throw new Error(`Pi Subagent unfinished
|
|
357
|
+
if (LEGACY_UNFINISHED_STATUSES.has(value.status)) {
|
|
358
|
+
throw new Error(`Pi Subagent unfinished v${value.version} request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
}
|
package/docs/orchestration.md
CHANGED
|
@@ -81,7 +81,7 @@ Direct `changeset` is rejected; use isolated mode for checked integration. Resou
|
|
|
81
81
|
|
|
82
82
|
### Dry run
|
|
83
83
|
|
|
84
|
-
Set `"dryRun": true` on a direct or isolated `delegate_task` request to check it without a launch. The dry run does the same checks as a launch: request shape, Role lookup, route and model with thinking level, Skills, tools, MCP allowlist, Role extensions, config, and `continueFrom` eligibility. For an isolated request, it also checks the task graph and that Main is clean and committed. If the request has a changeset task, it also
|
|
84
|
+
Set `"dryRun": true` on a direct or isolated `delegate_task` request to check it without a launch. The dry run does the same checks as a launch: request shape, Role lookup, route and model with thinking level, Skills, tools, MCP allowlist, Role extensions, config, and `continueFrom` eligibility. For an isolated request, it also checks the task graph and that Main is clean and committed. If the request has a changeset task, it also selects the Full suite, adds it to the final checks, and shows it. A failed check gives the same error as a launch.
|
|
85
85
|
|
|
86
86
|
The result is a bounded plan. It shows each task's Role, resolved `provider/model`, thinking level, access or kind, dependencies, and checks. An isolated plan also shows the final checks. The dry run makes no Herdr call, Herdr tab, session record, saved state, worktree, lease, checkout admission, or follow-up. The plan lists each check it cannot do without such an effect as `Not checked: <reason>`. Examples are the Herdr host preflight, checkout admission, and the stop of a continued agent.
|
|
87
87
|
|
|
@@ -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.
|
|
@@ -255,7 +257,7 @@ Every task declares `id`, `kind`, `role`, `modelClass`, `requirements`, `deliver
|
|
|
255
257
|
- A `changeset` task requires focused checks and owns one worktree, Herdr workspace, worker tab, and retained worker.
|
|
256
258
|
- `dependsOn` controls ready waves; dependent tasks can run from a selected staged snapshot only after Main explicitly advances it.
|
|
257
259
|
- `contextFrom` may reference completed text tasks only, implies scheduling dependencies, and preserves declaration order. It must not overlap `dependsOn`.
|
|
258
|
-
- A graph containing changesets requires final checks.
|
|
260
|
+
- A graph containing changesets requires final checks. Admission selects one Full suite: the `repoSuites` config entry, then `pnpm test` for a root `package.json` test script, then `make test` for a literal Makefile `test` target. It prepends the suite to the final checks if absent and saves the suite with its source in state v6. Combined validation rejects a changed root test script or Makefile. A config change after admission does not change the saved suite.
|
|
259
261
|
|
|
260
262
|
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.**
|
|
261
263
|
|
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
|
}
|
package/extensions/isolated.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type Pru
|
|
|
16
16
|
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
17
17
|
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
18
18
|
import { IsolatedRunner, nextIntegrationAction, type ExecutePlan, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
19
|
+
import { describeFullSuite } from "../dist/full-suite.js";
|
|
19
20
|
import {
|
|
20
21
|
IdOnlySchema,
|
|
21
22
|
ResumeRequestParameters,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
sameIdentity,
|
|
30
31
|
StatusOutputSchema,
|
|
31
32
|
type CheckBatchEvidence,
|
|
33
|
+
type CheckCommand,
|
|
32
34
|
type ExecuteRequest,
|
|
33
35
|
type ExecutionPolicySnapshot,
|
|
34
36
|
type ModelClass,
|
|
@@ -61,12 +63,14 @@ export type CreateIsolatedComponents = (options: {
|
|
|
61
63
|
executor: EphemeralSubagentExecutor;
|
|
62
64
|
policy: ExecutionPolicySnapshot;
|
|
63
65
|
currentPolicy(): ExecutionPolicySnapshot;
|
|
66
|
+
/** The configured Full suite overrides. Admission reads them after the policy check. */
|
|
67
|
+
repoSuites?(): Readonly<Record<string, CheckCommand>>;
|
|
64
68
|
onStateSaved(state: RunState): void;
|
|
65
69
|
}) => IsolatedExtensionComponents;
|
|
66
70
|
|
|
67
71
|
/** Construct the checked runtime while reusing the unified productive-assignment executor. */
|
|
68
72
|
export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
69
|
-
pi, context, executor, policy, currentPolicy, onStateSaved,
|
|
73
|
+
pi, context, executor, policy, currentPolicy, repoSuites, onStateSaved,
|
|
70
74
|
}) => {
|
|
71
75
|
const runProcess: DirectProcessRunner = async (command, args, options) => options.stdin === undefined
|
|
72
76
|
? await pi.exec(command, args, { cwd: options.cwd, signal: options.signal, timeout: options.timeoutMs })
|
|
@@ -96,6 +100,8 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
96
100
|
executor,
|
|
97
101
|
policy,
|
|
98
102
|
currentPolicy,
|
|
103
|
+
undefined,
|
|
104
|
+
repoSuites,
|
|
99
105
|
),
|
|
100
106
|
};
|
|
101
107
|
};
|
|
@@ -473,7 +479,8 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
473
479
|
}
|
|
474
480
|
|
|
475
481
|
/** Bounded dry-run plan; a route missing from the coordinator report shows as unresolved. */
|
|
476
|
-
export function formatExecutePlan(
|
|
482
|
+
export function formatExecutePlan(plan: ExecutePlan, hostChecked: boolean): { content: [{ type: "text"; text: string }]; details: unknown } {
|
|
483
|
+
const { request, root, main, routes } = plan;
|
|
477
484
|
const route = (role: string, modelClass: ModelClass) => {
|
|
478
485
|
const found = routes.find((candidate) => candidate.role === role && candidate.modelClass === modelClass);
|
|
479
486
|
return found ? { model: found.model, thinkingLevel: found.thinkingLevel } : undefined;
|
|
@@ -495,6 +502,7 @@ export function formatExecutePlan({ request, root, main, routes }: ExecutePlan,
|
|
|
495
502
|
...(task.kind === "changeset" ? [` checks: ${task.checks.map(formatCheckCommand).join("; ")}`,
|
|
496
503
|
...(task.judgment ? [` judgment: ${label(task.judgment.role, task.judgment.modelClass)}`] : [])] : []),
|
|
497
504
|
].join("\n")),
|
|
505
|
+
...(plan.fullSuite ? [`Full suite: ${describeFullSuite(plan.fullSuite)}`] : []),
|
|
498
506
|
`Final checks: ${request.finalChecks.map(formatCheckCommand).join("; ") || "none"}`,
|
|
499
507
|
...(request.finalJudgment ? [`Final judgment: ${label(request.finalJudgment.role, request.finalJudgment.modelClass)}`] : []),
|
|
500
508
|
...notChecked.map((reason) => `Not checked: ${reason}.`),
|
|
@@ -507,6 +515,7 @@ export function formatExecutePlan({ request, root, main, routes }: ExecutePlan,
|
|
|
507
515
|
...route(task.role, task.modelClass), dependsOn: task.dependsOn, contextFrom: task.contextFrom,
|
|
508
516
|
...(task.kind === "changeset" ? { checks: task.checks, ...(task.judgment ? { judgment: { ...task.judgment, ...route(task.judgment.role, task.judgment.modelClass) } } : {}) } : {}) })),
|
|
509
517
|
finalChecks: request.finalChecks,
|
|
518
|
+
...(plan.fullSuite ? { fullSuite: plan.fullSuite } : {}),
|
|
510
519
|
...(request.finalJudgment ? { finalJudgment: { ...request.finalJudgment, ...route(request.finalJudgment.role, request.finalJudgment.modelClass) } } : {}),
|
|
511
520
|
notChecked,
|
|
512
521
|
},
|
|
@@ -533,6 +542,8 @@ export interface RegisterIsolatedOptions {
|
|
|
533
542
|
executor: EphemeralSubagentExecutor;
|
|
534
543
|
policy: ExecutionPolicySnapshot;
|
|
535
544
|
currentPolicy(): ExecutionPolicySnapshot;
|
|
545
|
+
/** The configured Full suite overrides for isolated changesets. */
|
|
546
|
+
repoSuites?(): Readonly<Record<string, CheckCommand>>;
|
|
536
547
|
/** Report new config issues, and throw when the config now blocks delegation, before each deferred Role launch. */
|
|
537
548
|
beforeRoleLaunch?(ctx: ExtensionContext): void;
|
|
538
549
|
componentsFactory?: CreateIsolatedComponents;
|
|
@@ -566,6 +577,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
566
577
|
executor: options.executor,
|
|
567
578
|
policy: options.policy,
|
|
568
579
|
currentPolicy: options.currentPolicy,
|
|
580
|
+
...(options.repoSuites ? { repoSuites: options.repoSuites } : {}),
|
|
569
581
|
onStateSaved: (state) => {
|
|
570
582
|
savedRevision += 1;
|
|
571
583
|
for (const listener of stateListeners) listener(state);
|
|
@@ -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);
|
|
@@ -251,6 +255,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
251
255
|
executor,
|
|
252
256
|
policy: initialPolicy,
|
|
253
257
|
currentPolicy,
|
|
258
|
+
// Admission reads these after currentPolicy, which throws for a disabled config.
|
|
259
|
+
repoSuites: () => { const loaded = configStore.loadSync(); return loaded.status === "ok" ? loaded.value.repoSuites : {}; },
|
|
254
260
|
beforeRoleLaunch,
|
|
255
261
|
});
|
|
256
262
|
let directSequence = 0;
|
|
@@ -634,7 +640,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
634
640
|
};
|
|
635
641
|
|
|
636
642
|
/** 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) => {
|
|
643
|
+
const planDirect = async (workflow: ParsedWorkflow, sources: ReadonlyMap<number, DirectTabRecord & { turn: string }>, cwd: string, targets: readonly string[], ctx: ExtensionContext) => {
|
|
638
644
|
for (const [index, source] of sources) {
|
|
639
645
|
try { await assertSettledDirectSession(source.sessionFile, source.turn); } catch (error) {
|
|
640
646
|
throw boundedError(new Error(`Cannot continue ${workflow.delegations[index]!.continueFrom}: ${error instanceof Error ? error.message : String(error)}.`, { cause: error }));
|
|
@@ -652,6 +658,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
652
658
|
directTaskReader(workflow.mode === "chain" ? delegation.task.replaceAll("{previous}", "") : delegation.task, prepared.tools);
|
|
653
659
|
} catch (error) { throw boundedError(error); }
|
|
654
660
|
entries.push({ index, name, role, access, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel,
|
|
661
|
+
...(delegation.repo === undefined ? {} : { repo: delegation.repo, cwd: targets[index]! }),
|
|
655
662
|
...(continueFrom === undefined ? {} : { continueFrom }), ...(workflow.mode === "chain" && index ? { after: index } : {}) });
|
|
656
663
|
}
|
|
657
664
|
const writes = writesCheckout(workflow);
|
|
@@ -663,8 +670,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
663
670
|
];
|
|
664
671
|
const text = [
|
|
665
672
|
`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 ?
|
|
673
|
+
...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}` : ""}`),
|
|
674
|
+
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
675
|
: "Read request: tasks would run concurrently without checkout admission.",
|
|
669
676
|
...notChecked.map((reason) => `Not checked: ${reason}.`),
|
|
670
677
|
].join("\n");
|
|
@@ -720,7 +727,25 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
720
727
|
const branchTabs = ctx.sessionManager.getBranch().flatMap((entry) =>
|
|
721
728
|
entry.type === "custom" && entry.customType === DIRECT_TAB_TYPE && entry.data ? [entry.data as DirectTabRecord] : []);
|
|
722
729
|
const policy = currentPolicy();
|
|
723
|
-
|
|
730
|
+
// Resolve each Repository target before continuation checks, admission evidence, or Herdr calls.
|
|
731
|
+
const targets: string[] = [];
|
|
732
|
+
for (const [index, { repo }] of workflow.delegations.entries()) {
|
|
733
|
+
if (repo === undefined) { targets.push(ctx.cwd); continue; }
|
|
734
|
+
try {
|
|
735
|
+
targets.push((await resolveRepoTarget(ctx.cwd, repo, signal ? { signal } : {})).root);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
throw boundedError(new Error(`Task ${index + 1} repo ${repo}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Workers open in the caller's Herdr workspace with the target as their working directory.
|
|
741
|
+
const herdrByCwd = new Map<string, ReturnType<typeof createDirectHerdr>>();
|
|
742
|
+
const herdrFor = (cwd: string) => {
|
|
743
|
+
let client = herdrByCwd.get(cwd);
|
|
744
|
+
if (!client) herdrByCwd.set(cwd, client = createDirectHerdr(pi, cwd, policy.childIdleMs));
|
|
745
|
+
return client;
|
|
746
|
+
};
|
|
747
|
+
const herdr = herdrFor(ctx.cwd);
|
|
748
|
+
const entryCwd = (index: number) => herdrFor(targets[index]!).cwd;
|
|
724
749
|
const sources = new Map<number, DirectTabRecord & { turn: string }>();
|
|
725
750
|
for (const [index, { continueFrom, role }] of workflow.delegations.entries()) {
|
|
726
751
|
if (continueFrom === undefined) continue;
|
|
@@ -731,27 +756,28 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
731
756
|
const source = matches[0]!;
|
|
732
757
|
if (source.display!.role !== role) throw refuse(`it ran with Role ${source.display!.role}, not ${role}; use the recorded Role`);
|
|
733
758
|
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
|
-
|
|
759
|
+
const target = herdrFor(targets[index]!);
|
|
760
|
+
if (source.workspaceId !== target.workspaceId || source.cwd !== target.cwd) {
|
|
761
|
+
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
762
|
}
|
|
737
763
|
if ([...directTasks.values()].some(({ tabs, continues }) => continues?.has(source.sessionFile)
|
|
738
764
|
|| tabs.some(({ sessionFile }) => sessionFile === source.sessionFile))) throw refuse("a locally active direct task still owns its session");
|
|
739
765
|
if ([...sources.values()].some(({ sessionFile }) => sessionFile === source.sessionFile)) throw refuse("this request already continues its session");
|
|
740
766
|
sources.set(index, { ...source, turn: source.turn });
|
|
741
767
|
}
|
|
742
|
-
if (parsed.dryRun) return await planDirect(workflow, sources, herdr.cwd, latestCtx ?? ctx);
|
|
768
|
+
if (parsed.dryRun) return await planDirect(workflow, sources, herdr.cwd, workflow.delegations.map((_, index) => entryCwd(index)), latestCtx ?? ctx);
|
|
743
769
|
const writes = writesCheckout(workflow);
|
|
744
770
|
const entries = identifyWorkflowEntries(toolCallId, workflow);
|
|
745
771
|
const states = new Map<string, WorkflowTransportEntry>(entries.map(({ id, index, delegation }) => [id, {
|
|
746
772
|
id, index, role: delegation.role, status: "pending",
|
|
747
|
-
name: delegation.continueFrom === undefined ? delegation.name : `${delegation.name} (continued from ${delegation.continueFrom})`,
|
|
773
|
+
name: `${delegation.continueFrom === undefined ? delegation.name : `${delegation.name} (continued from ${delegation.continueFrom})`}${delegation.repo === undefined ? "" : ` · repo ${delegation.repo}`}`,
|
|
748
774
|
}]));
|
|
749
775
|
const startedAt = new Map<string, number>();
|
|
750
776
|
// Launch snapshots: write requests report changes; read requests detect a stale checkout.
|
|
751
777
|
const snapshots = new Map<string, CheckoutSnapshot | Error | undefined>();
|
|
752
|
-
const checkout = async ({ id, delegation }: WorkflowEntry) => {
|
|
778
|
+
const checkout = async ({ id, index, delegation }: WorkflowEntry) => {
|
|
753
779
|
if (!snapshots.has(id)) return {};
|
|
754
|
-
const note = await checkoutNote(
|
|
780
|
+
const note = await checkoutNote(entryCwd(index), snapshots.get(id), writes, delegation.paths);
|
|
755
781
|
return note ? { checkout: note } : {};
|
|
756
782
|
};
|
|
757
783
|
const measured = (id: string, usage: DirectUsage | undefined) => ({
|
|
@@ -780,6 +806,16 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
780
806
|
const settled = new Promise<void>((resolve) => { resolveSettled = resolve; });
|
|
781
807
|
// Register before Herdr can create a tab: session switches must await this
|
|
782
808
|
// launch, including its identity callback, before copying recovery records.
|
|
809
|
+
if (writes) {
|
|
810
|
+
// A selector can point somewhere else after admission resolved it, for example through a symlink.
|
|
811
|
+
// Launch only in the exact checkouts that admission reserved.
|
|
812
|
+
const admitted = holdAdmission.admitted(toolCallId) ?? [];
|
|
813
|
+
for (const [index, { repo }] of workflow.delegations.entries()) {
|
|
814
|
+
if (repo !== undefined && !admitted.includes(targets[index]!)) {
|
|
815
|
+
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.`));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
783
819
|
const releaseAdmission = writes ? holdAdmission(toolCallId) : () => {};
|
|
784
820
|
const local: NonNullable<ReturnType<typeof directTasks.get>> = { controller, settled, handles, tabs,
|
|
785
821
|
continues: new Set([...sources.values()].map(({ sessionFile }) => sessionFile)) };
|
|
@@ -795,7 +831,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
795
831
|
const proof = (async () => {
|
|
796
832
|
entryControllers.get(entryId)!.abort();
|
|
797
833
|
await handle.cancel().catch(() => undefined);
|
|
798
|
-
await
|
|
834
|
+
await herdrFor(tab.cwd ?? ctx.cwd).stop(tab, inScope);
|
|
799
835
|
})();
|
|
800
836
|
stopProofs.set(entryId, proof);
|
|
801
837
|
return proof;
|
|
@@ -805,7 +841,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
805
841
|
const inScope = () => current() && (local.closeScope?.() ?? true);
|
|
806
842
|
try {
|
|
807
843
|
if (local.allocation) throw new Error(local.allocation.failure);
|
|
808
|
-
for (const tab of tabs) await
|
|
844
|
+
for (const tab of tabs) await herdrFor(tab.cwd ?? ctx.cwd).stop(tab, inScope);
|
|
809
845
|
if (!inScope()) throw new Error("Direct task scope changed; reopen /subagent.");
|
|
810
846
|
releaseAdmission();
|
|
811
847
|
directTasks.delete(taskId);
|
|
@@ -831,10 +867,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
831
867
|
startWidgetItem(entry.id, taskId, display, prepared.model.id, prepared.thinkingLevel, ctx);
|
|
832
868
|
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
833
869
|
promptArgIndex: prepared.promptArgIndex }, activeSignal);
|
|
834
|
-
snapshots.set(entry.id, await snapshotCheckout(
|
|
870
|
+
snapshots.set(entry.id, await snapshotCheckout(entryCwd(entry.index)).catch((error: unknown) => error instanceof Error ? error : new Error(String(error))));
|
|
835
871
|
let handle: DirectHandle;
|
|
836
872
|
try {
|
|
837
|
-
handle = await
|
|
873
|
+
handle = await herdrFor(targets[entry.index]!).start({ ...prepared, args: [...transient.launch.args] },
|
|
838
874
|
`d-${randomUUID().replaceAll("-", "").slice(0, 24)}`, `${directRef(display)} ${display.title}`,
|
|
839
875
|
`${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
876
|
const record = { taskId, entryId: entry.id, display, ...tab };
|
|
@@ -867,7 +903,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
867
903
|
try {
|
|
868
904
|
await assertSettledDirectSession(source.sessionFile, source.turn);
|
|
869
905
|
// Close only an idle or done exact agent; an absent one still needs two empty lease scans.
|
|
870
|
-
await
|
|
906
|
+
await herdrFor(targets[index]!).stop(source, () => launchEpoch === sessionEpoch && !controller.signal.aborted, true);
|
|
871
907
|
} catch (error) {
|
|
872
908
|
throw new Error(`Cannot continue ${workflow.delegations[index]!.continueFrom}: ${error instanceof Error ? error.message : String(error)}.`, { cause: error });
|
|
873
909
|
}
|