@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/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;
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
- const V4_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
22
- const V4_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
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 !== 4 || !("status" in value) || typeof value.status !== "string"
352
- || (!V4_TERMINAL_STATUSES.has(value.status) && !V4_UNFINISHED_STATUSES.has(value.status))) {
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 (V4_UNFINISHED_STATUSES.has(value.status)) {
356
- throw new Error(`Pi Subagent unfinished v4 request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
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
  }
@@ -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 checks the canonical root `package.json` test script and adds `pnpm test` to the final checks. A failed check gives the same error as a launch.
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. The repository root must have a `package.json` test script; admission prepends canonical `pnpm test` if absent from final checks. Combined validation rejects a changed root test script.
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
 
@@ -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 that checkout.
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 function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): (toolCallId: string) => () => void {
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 ownerByCheckout = new Map<string, string>();
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
- if (direct ? !directCanWrite(event.input) : MAIN_NON_WRITING_TOOLS.has(event.toolName)) return;
47
- const call: { direct: boolean; key?: string } = { direct };
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
- const key = await lookup(ctx);
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
- const owner = ownerByCheckout.get(key);
54
- const main = direct ? [...calls].find(([, active]) => !active.direct && active.key === key)?.[0] : undefined;
55
- if (owner || main) {
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 ${owner ? `an admitted direct writer (${owner})` : `a Main potentially-writing call in flight (${main})`}; retry after it stops, or use mode: isolated to keep Main free.`,
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.key = key;
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
- return (toolCallId) => {
107
+ const hold = (toolCallId: string) => {
75
108
  const call = calls.get(toolCallId);
76
- if (!call?.direct || !call.key) {
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
  }
@@ -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({ request, root, main, routes }: ExecutePlan, hostChecked: boolean): { content: [{ type: "text"; text: string }]; details: unknown } {
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
+ }
@@ -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
- const canWrite = (input: unknown): boolean => {
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, canWrite);
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 ? "Write request: tasks would run serially and reserve this checkout until owned workers are proved stopped."
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
- const herdr = createDirectHerdr(pi, ctx.cwd, policy.childIdleMs);
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
- if (source.workspaceId !== herdr.workspaceId || source.cwd !== herdr.cwd) {
735
- throw refuse(`it ran in ${source.cwd ?? "an unrecorded checkout"} (Herdr workspace ${source.workspaceId ?? "unrecorded"}), not this checkout and workspace; start a fresh task here`);
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(ctx.cwd, snapshots.get(id), writes, delegation.paths);
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 herdr.stop(tab, inScope);
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 herdr.stop(tab, inScope);
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(ctx.cwd).catch((error: unknown) => error instanceof Error ? error : new Error(String(error))));
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 herdr.start({ ...prepared, args: [...transient.launch.args] },
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 herdr.stop(source, () => launchEpoch === sessionEpoch && !controller.signal.aborted, true);
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
  }