@hcflabs/pi-subagent 28.0.0 → 28.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,7 +108,11 @@ When Pi runs in a parent directory that holds many repositories, set `repo` to s
108
108
 
109
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
110
 
111
- Isolated mode does not accept `repo` yet.
111
+ An isolated request accepts one `repo` for the whole graph. Tasks cannot select another repository. The request then uses the Repository target as its checkout. State, worker worktrees, validation, and promotion belong to that repository. The Pi working directory does not change.
112
+
113
+ Your Herdr workspace can belong to another checkout or to none. Herdr opens the primary workspace of the target when it opens the first worker worktree, and it does not close that workspace later. Pass the same `repo` to `subagent_status`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort`.
114
+
115
+ Run `/subagent` in a parent directory that is not a Git repository. It lists the isolated requests of each Repository target below it, with their `repo`. `/subagent recover` recovers each Repository target that has saved requests. `/subagent prune` still needs a Git checkout.
112
116
 
113
117
  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).
114
118
 
@@ -38,9 +38,15 @@ export declare class HerdrHostRuntime implements HostRuntime {
38
38
  private readonly lsofCommand;
39
39
  private readonly executionBudget?;
40
40
  constructor(options: HerdrHostRuntimeOptions);
41
- /** Fail-closed Herdr caller/capability gate. This method creates no state or host resource. */
41
+ /**
42
+ * Fail-closed Herdr caller/capability gate. This method creates no state or host resource.
43
+ * Without `targeted`, the caller's workspace must be the checkout `root`. With `targeted`, `root` is a
44
+ * Repository target, and the caller's workspace can belong to any checkout or to none.
45
+ * Herdr opens the primary workspace of the target when it opens an owned worktree.
46
+ */
42
47
  preflightHost(input: {
43
48
  root: string;
49
+ targeted?: boolean;
44
50
  }, context: OperationContext): Promise<void>;
45
51
  planHostAllocation(input: {
46
52
  readonly requestId: ExecuteRequest["id"];
@@ -270,7 +270,12 @@ export class HerdrHostRuntime {
270
270
  this.lsofCommand = options.lsofCommand ?? "lsof";
271
271
  this.executionBudget = options.executionBudget;
272
272
  }
273
- /** Fail-closed Herdr caller/capability gate. This method creates no state or host resource. */
273
+ /**
274
+ * Fail-closed Herdr caller/capability gate. This method creates no state or host resource.
275
+ * Without `targeted`, the caller's workspace must be the checkout `root`. With `targeted`, `root` is a
276
+ * Repository target, and the caller's workspace can belong to any checkout or to none.
277
+ * Herdr opens the primary workspace of the target when it opens an owned worktree.
278
+ */
274
279
  async preflightHost(input, context) {
275
280
  context.signal.throwIfAborted();
276
281
  if (this.env.HERDR_ENV !== "1")
@@ -306,6 +311,11 @@ export class HerdrHostRuntime {
306
311
  const workspaceId = exactString(pane.workspace_id, "Herdr current workspace_id");
307
312
  const workspaceResponse = await this.herdr.json(["workspace", "get", workspaceId], this.processOptions(root, context, HERDR_OPERATION_CAP_MS));
308
313
  const workspace = parseWorkspaceInfo(workspaceResponse, workspaceId);
314
+ if (input.targeted) {
315
+ // The worker worktrees open from the primary checkout of the target, not from the caller's workspace.
316
+ await this.repositoryIdentity(root, context);
317
+ return;
318
+ }
309
319
  const worktree = record(workspace.worktree, "Herdr current workspace worktree");
310
320
  const checkout = await realpath(exactString(worktree.checkout_path, "Herdr current checkout_path"));
311
321
  if (checkout !== root)
@@ -16,6 +16,7 @@ export interface LaunchRuntimeOptions {
16
16
  request: ExecuteRequest;
17
17
  cwd: string;
18
18
  root: string;
19
+ targeted?: boolean;
19
20
  }, context: OperationContext): Promise<void>;
20
21
  inspectMainBase(input: {
21
22
  root: string;
@@ -44,6 +45,7 @@ export declare class RoleLaunchRuntime implements CoordinatorRuntime {
44
45
  request: ExecuteRequest;
45
46
  cwd: string;
46
47
  dryRun?: boolean;
48
+ targeted?: boolean;
47
49
  }, context: OperationContext): Promise<{
48
50
  root: string;
49
51
  main: WorkspaceIdentity;
@@ -140,12 +140,15 @@ export class RoleLaunchRuntime {
140
140
  const root = await realpath(resolvedRoot);
141
141
  if (root !== resolvedRoot)
142
142
  throw new Error("Pi Subagent root resolver returned a non-canonical path.");
143
+ // A Repository target must still be its own Git top-level directory before the host gate runs.
144
+ if (input.targeted && root !== await realpath(input.cwd))
145
+ throw new Error(`Repository target ${input.cwd} now resolves to ${root}; nothing was saved. Retry the request.`);
143
146
  const rootInfo = await lstat(root);
144
147
  if (!rootInfo.isDirectory())
145
148
  throw new Error("Pi Subagent root must be an existing local directory.");
146
149
  context.signal?.throwIfAborted();
147
150
  if (!input.dryRun && input.request.tasks.some((task) => task.kind === "changeset")) {
148
- await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root }, context);
151
+ await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root, ...(input.targeted ? { targeted: true } : {}) }, context);
149
152
  }
150
153
  const main = await this.options.inspectMainBase({ root }, context);
151
154
  const required = new Map();
package/dist/runner.d.ts CHANGED
@@ -119,11 +119,15 @@ export declare function withTransientLaunch<Launch extends VerifiedLaunch, Resul
119
119
  export interface CoordinatorRuntime {
120
120
  now(): number;
121
121
  randomToken(): string;
122
- /** `dryRun` skips checks that call Herdr; every other check runs. */
122
+ /**
123
+ * `dryRun` skips checks that call Herdr; every other check runs.
124
+ * `targeted` means that `cwd` is a Repository target, so the caller's Herdr workspace can belong to another checkout.
125
+ */
123
126
  preflight(input: {
124
127
  request: ExecuteRequest;
125
128
  cwd: string;
126
129
  dryRun?: boolean;
130
+ targeted?: boolean;
127
131
  }, context: OperationContext): Promise<{
128
132
  root: string;
129
133
  main: WorkspaceIdentity;
@@ -361,8 +365,13 @@ export declare class IsolatedRunner {
361
365
  /** Every execute check before durable state; a dry run also skips the Herdr host preflight. */
362
366
  private admitExecute;
363
367
  /** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
364
- plan(value: unknown, cwd: string, outerSignal?: AbortSignal): Promise<ExecutePlan>;
365
- execute(value: unknown, cwd: string, outerSignal?: AbortSignal): Promise<RunResponse>;
368
+ /** `options.targeted` marks `cwd` as a Repository target that the caller selected with `repo`. */
369
+ plan(value: unknown, cwd: string, outerSignal?: AbortSignal, options?: {
370
+ targeted?: boolean;
371
+ }): Promise<ExecutePlan>;
372
+ execute(value: unknown, cwd: string, outerSignal?: AbortSignal, options?: {
373
+ targeted?: boolean;
374
+ }): Promise<RunResponse>;
366
375
  resume(value: unknown, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
367
376
  /** Main selects an exact retained candidate; only the owned integration checkout is writable. */
368
377
  stage(value: unknown, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
@@ -386,6 +395,20 @@ export declare class IsolatedRunner {
386
395
  tasks: never[];
387
396
  }[];
388
397
  }>;
398
+ /** Orphaned requests of every primary checkout in one state scan, each with the primary checkout that owns it. */
399
+ listAllOrphanRequests(): Promise<{
400
+ invalidIds: string[];
401
+ requests: {
402
+ id: string;
403
+ name: string;
404
+ status: string;
405
+ directory: string;
406
+ root: string;
407
+ orphaned: true;
408
+ tasks: never[];
409
+ primary: string;
410
+ }[];
411
+ }>;
389
412
  private discardGuards;
390
413
  discardPlan(cwd: string, id: string, directory: string | undefined, guards: DiscardGuards): Promise<DiscardPlan>;
391
414
  /** Command-only explicit discard. No tool calls this method. */
package/dist/runner.js CHANGED
@@ -814,14 +814,19 @@ export class IsolatedRunner {
814
814
  throw error;
815
815
  }
816
816
  /** Every execute check before durable state; a dry run also skips the Herdr host preflight. */
817
- async admitExecute(value, cwd, scope, dryRun) {
817
+ async admitExecute(value, cwd, scope, dryRun, targeted) {
818
818
  const request = parseExecuteRequest(value);
819
819
  const policy = Object.freeze({ ...this.currentPolicy() });
820
820
  const canonicalCwd = realpathSync.native(cwd);
821
- const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({ request, cwd: canonicalCwd, ...(dryRun ? { dryRun } : {}) }, context));
821
+ const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({
822
+ request, cwd: canonicalCwd, ...(dryRun ? { dryRun } : {}), ...(targeted ? { targeted } : {}),
823
+ }, context));
822
824
  const root = realpathSync.native(prepared.root);
823
825
  if (root !== prepared.root)
824
826
  throw new Error("Preflight repository root must be canonical.");
827
+ // A Repository target is its own root. A different root means the checkout changed after the target check.
828
+ if (targeted && root !== canonicalCwd)
829
+ throw new Error(`Repository target ${canonicalCwd} now resolves to ${root}; nothing was saved. Retry the request.`);
825
830
  let fullSuite;
826
831
  if (request.tasks.some((task) => task.kind === "changeset")) {
827
832
  // Main is clean and committed here, so the working files are the committed sources.
@@ -844,13 +849,14 @@ export class IsolatedRunner {
844
849
  return { request, policy, root, preparedMain, routes: prepared.routes ?? [], ...(fullSuite ? { fullSuite } : {}) };
845
850
  }
846
851
  /** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
847
- async plan(value, cwd, outerSignal) {
848
- const { request, root, preparedMain, routes, fullSuite } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true);
852
+ /** `options.targeted` marks `cwd` as a Repository target that the caller selected with `repo`. */
853
+ async plan(value, cwd, outerSignal, options = {}) {
854
+ const { request, root, preparedMain, routes, fullSuite } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true, options.targeted === true);
849
855
  return { request, root, main: preparedMain, routes, ...(fullSuite ? { fullSuite } : {}) };
850
856
  }
851
- async execute(value, cwd, outerSignal) {
857
+ async execute(value, cwd, outerSignal, options = {}) {
852
858
  const scope = new ProductiveScope(outerSignal);
853
- const { request, policy, root, preparedMain, fullSuite } = await this.admitExecute(value, cwd, scope, false);
859
+ const { request, policy, root, preparedMain, fullSuite } = await this.admitExecute(value, cwd, scope, false, options.targeted === true);
854
860
  return await this.withProductiveRun(root, async (lifecycle) => {
855
861
  const createdAt = this.coordinatorRuntime.now();
856
862
  const state = {
@@ -1788,6 +1794,14 @@ export class IsolatedRunner {
1788
1794
  directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
1789
1795
  })) };
1790
1796
  }
1797
+ /** Orphaned requests of every primary checkout in one state scan, each with the primary checkout that owns it. */
1798
+ async listAllOrphanRequests() {
1799
+ const { states, invalidIds, primaries } = await this.store.listOrphans();
1800
+ return { invalidIds, requests: states.map((handle) => ({
1801
+ id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state, true),
1802
+ directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [], primary: primaries.get(handle),
1803
+ })) };
1804
+ }
1791
1805
  discardGuards(directory, root, guards) {
1792
1806
  return { ...guards, blocked: async () => {
1793
1807
  if (this.productiveLifecycles.has(root))
package/dist/store.d.ts CHANGED
@@ -47,11 +47,19 @@ export declare class FileRunStore {
47
47
  private loadFromValidatedDirectory;
48
48
  hasProductiveRunLeaseInDirectory(directory: string): Promise<boolean>;
49
49
  withDirectoryLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
50
- /** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
50
+ /**
51
+ * Missing roots only; repoRoot records the stable primary checkout, not the removed parent.
52
+ * Without `primary`, return the orphans of every primary checkout, each with its primary.
53
+ */
51
54
  listOrphans(primary: string): Promise<{
52
55
  states: RunStateHandle[];
53
56
  invalidIds: string[];
54
57
  }>;
58
+ listOrphans(primary?: undefined): Promise<{
59
+ states: RunStateHandle[];
60
+ invalidIds: string[];
61
+ primaries: Map<RunStateHandle, string>;
62
+ }>;
55
63
  private stateEntries;
56
64
  /** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
57
65
  assertLegacyAdmissionSafe(root: string): Promise<void>;
package/dist/store.js CHANGED
@@ -255,8 +255,8 @@ export class FileRunStore {
255
255
  await release();
256
256
  }
257
257
  }
258
- /** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
259
258
  async listOrphans(primary) {
259
+ const primaries = new Map();
260
260
  const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
261
261
  let directories;
262
262
  try {
@@ -264,7 +264,7 @@ export class FileRunStore {
264
264
  }
265
265
  catch (error) {
266
266
  if (isMissing(error))
267
- return { states: [], invalidIds: [] };
267
+ return { states: [], invalidIds: [], ...(primary === undefined ? { primaries } : {}) };
268
268
  throw error;
269
269
  }
270
270
  const states = [];
@@ -291,7 +291,14 @@ export class FileRunStore {
291
291
  throw error;
292
292
  }
293
293
  const worktrees = discardResources(handle.state).flatMap((resource) => resource.worktree ? [resource.worktree] : []);
294
- if (worktrees.length && worktrees.every((item) => item.repoRoot === primary))
294
+ const owner = worktrees[0]?.repoRoot;
295
+ if (owner === undefined || !worktrees.every((item) => item.repoRoot === owner))
296
+ continue;
297
+ if (primary === undefined) {
298
+ primaries.set(handle, owner);
299
+ states.push(handle);
300
+ }
301
+ else if (owner === primary)
295
302
  states.push(handle);
296
303
  }
297
304
  catch {
@@ -305,7 +312,7 @@ export class FileRunStore {
305
312
  invalidIds.push(directory);
306
313
  }
307
314
  }
308
- return { states, invalidIds };
315
+ return { states, invalidIds, ...(primary === undefined ? { primaries } : {}) };
309
316
  }
310
317
  async stateEntries(root) {
311
318
  try {
@@ -1,7 +1,9 @@
1
- import { basename } from "node:path";
1
+ import { basename, relative, sep } from "node:path";
2
2
  import type { JsonValue } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateToWidth } from "@earendil-works/pi-tui";
5
+ import { discoverRepos, gitTopLevel, isWithin, resolveRepoTarget, type RepoDiscovery } from "@hcflabs/pi-repo";
6
+ import { Type, type TObject, type TProperties } from "typebox";
5
7
  import { capEphemeralSubagentOutput, utf8Prefix, type EphemeralSubagentExecutor } from "../dist/ephemeral.js";
6
8
  import {
7
9
  createCanonicalGitRootResolver,
@@ -43,6 +45,7 @@ import { FileRunStore } from "../dist/store.js";
43
45
  import { workerOutputForMain } from "./result-transport.ts";
44
46
  import type { CleanupReport, IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
45
47
  import type { DiscardPlan } from "../dist/discard.js";
48
+ import { normalizeRepo } from "./workflow.ts";
46
49
 
47
50
  const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
48
51
  const PUBLIC_EVIDENCE_MAX_BYTES = 512;
@@ -87,7 +90,7 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
87
90
  pi,
88
91
  context,
89
92
  resolveRoot,
90
- preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
93
+ preflightHost: async ({ root, targeted }, operation) => await host.preflightHost({ root, ...(targeted ? { targeted } : {}) }, operation),
91
94
  inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
92
95
  });
93
96
  return {
@@ -522,10 +525,32 @@ export function formatExecutePlan(plan: ExecutePlan, hostChecked: boolean): { co
522
525
  };
523
526
  }
524
527
 
528
+ const RepoParameter = Type.Optional(Type.String({
529
+ minLength: 1,
530
+ description: "Repository target of the request: the same repo that delegate_task used, as a path relative to the Pi working directory. Omit it when the request runs in the checkout of the Pi working directory.",
531
+ }));
532
+
533
+ /** Add the optional `repo` selector to a flat tool schema. The selector is not part of the durable request. */
534
+ function withRepo<P extends TProperties>(schema: TObject<P>): TObject<P & { repo: typeof RepoParameter }> {
535
+ return Type.Object({ ...schema.properties, repo: RepoParameter }, { additionalProperties: false }) as unknown as TObject<P & { repo: typeof RepoParameter }>;
536
+ }
537
+
538
+ /** Parse the request without `repo`, then add `repo` back as a separate selector. */
539
+ function parseWithRepo<T extends object>(parse: (value: unknown) => T): (value: unknown) => T & { repo?: string } {
540
+ return (value) => {
541
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "repo")) return parse(value);
542
+ const { repo, ...rest } = value as Record<string, unknown>;
543
+ if (typeof repo !== "string") throw new Error("repo must be a relative path.");
544
+ // Normalize like delegate_task, so a recovery call selects the same Repository target.
545
+ return { ...parse(rest), repo: normalizeRepo(repo, "repo") };
546
+ };
547
+ }
548
+
525
549
  export interface IsolatedSurface {
526
550
  /** Dry run of execute: validates and resolves without durable state or Herdr calls. */
527
- plan(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof formatExecutePlan>>;
528
- execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
551
+ /** `repo` selects a Repository target below the Pi working directory. */
552
+ plan(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext, repo?: string): Promise<ReturnType<typeof formatExecutePlan>>;
553
+ execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext, repo?: string): Promise<ReturnType<typeof toolResult>>;
529
554
  inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
530
555
  recover(cwd: string): Promise<string>;
531
556
  prunePlan(cwd: string): Promise<PrunePlan>;
@@ -587,14 +612,20 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
587
612
  }
588
613
  },
589
614
  });
590
- const lookupRoot = async (cwd: string, signal?: AbortSignal): Promise<string> => {
615
+ /** The canonical checkout root: the Repository target `repo` below `cwd`, or the checkout that contains `cwd`. */
616
+ const lookupRoot = async (cwd: string, signal?: AbortSignal, repo?: string): Promise<string> => {
591
617
  const startedAt = Date.now();
592
618
  const context: OperationContext = {
593
619
  signal: signal ?? new AbortController().signal,
594
620
  timeoutMs: LOOKUP_ROOT_TIMEOUT_MS,
595
621
  deadline: startedAt + LOOKUP_ROOT_TIMEOUT_MS,
596
622
  };
597
- return await getComponents().resolveRoot(cwd, context);
623
+ if (repo === undefined) return await getComponents().resolveRoot(cwd, context);
624
+ const target = (await resolveRepoTarget(cwd, repo, { signal: context.signal, timeoutMs: LOOKUP_ROOT_TIMEOUT_MS })).root;
625
+ const root = await getComponents().resolveRoot(target, context);
626
+ // The checkout can change between two Git probes. Use only the root that the target validation proved.
627
+ if (root !== target) throw new Error(`Repository target ${repo} resolved to ${target}, but Git now reports ${root}. Nothing was changed; retry.`);
628
+ return root;
598
629
  };
599
630
 
600
631
  pi.on("session_start", (_event, ctx) => {
@@ -756,12 +787,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
756
787
  name: "subagent_status",
757
788
  label: "Subagent status",
758
789
  description: "Read one durable isolated request without reconciling or changing resources.",
759
- parameters: IdOnlySchema,
760
- prepareArguments: parseIdOnly,
790
+ parameters: withRepo(IdOnlySchema),
791
+ prepareArguments: parseWithRepo(parseIdOnly),
761
792
  outputSchema: StatusOutputSchema,
762
793
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
763
794
  latestCtx = ctx;
764
- const root = await lookupRoot(ctx.cwd, signal);
795
+ const root = await lookupRoot(ctx.cwd, signal, params.repo);
765
796
  const response = await getComponents().runner.status(params.id, root, signal);
766
797
  const result = toolResult(response, ctx, workspaceRowsByRequest);
767
798
  const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
@@ -773,12 +804,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
773
804
  name: "subagent_resume",
774
805
  label: "Subagent resume",
775
806
  description: "Resume one unfinished isolated request without resetting its recorded policy or correction count. For a retry that sends a same-worker correction, optional instruction (at most 32000 characters) is appended to that prompt as Main's instruction after the saved check or review failure; use it to name the exact fixes, including the review findings the worker must address. Other actions reject instruction.",
776
- parameters: ResumeRequestParameters,
777
- prepareArguments: parseResumeRequest,
807
+ parameters: withRepo(ResumeRequestParameters),
808
+ prepareArguments: parseWithRepo(parseResumeRequest),
778
809
  exposure: "model-only",
779
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
810
+ async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
780
811
  latestCtx = ctx;
781
- const root = await lookupRoot(ctx.cwd, signal);
812
+ const root = await lookupRoot(ctx.cwd, signal, repo);
782
813
  return await startInSession(params.id, root, signal, ctx,
783
814
  (state) => state.recovery?.kind === "resume" && state.recovery.action === params.action
784
815
  && (!("taskId" in params) || state.recovery.taskId === params.taskId),
@@ -789,12 +820,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
789
820
  name: "subagent_stage",
790
821
  label: "Subagent stage",
791
822
  description: "Main stages/resolves an exact candidate, or rejects/revises one. Rejection of a staged candidate freezes the old generation; explicitly restage chosen candidates in a new generation. Never writes Main.",
792
- parameters: StageRequestSchema,
793
- prepareArguments: parseStageRequest,
823
+ parameters: withRepo(StageRequestSchema),
824
+ prepareArguments: parseWithRepo(parseStageRequest),
794
825
  exposure: "model-only",
795
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
826
+ async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
796
827
  latestCtx = ctx;
797
- const root = await lookupRoot(ctx.cwd, signal);
828
+ const root = await lookupRoot(ctx.cwd, signal, repo);
798
829
  return toolResult(await getComponents().runner.stage(params, root, signal), ctx, workspaceRowsByRequest);
799
830
  },
800
831
  });
@@ -802,12 +833,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
802
833
  name: "subagent_integrate",
803
834
  label: "Subagent integrate",
804
835
  description: "Main advances staged dependents, refreshes after clean Main drift or evidence-less validation failure, validates in the background, corrects, promotes, reconciles interrupted validation/promotion, cleans up promoted resources, or explicitly releases rejected/superseded owned resources. Never replays an uncertain mutation.",
805
- parameters: IntegrationActionParameters,
806
- prepareArguments: parseIntegrationAction,
836
+ parameters: withRepo(IntegrationActionParameters),
837
+ prepareArguments: parseWithRepo(parseIntegrationAction),
807
838
  exposure: "model-only",
808
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
839
+ async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
809
840
  latestCtx = ctx;
810
- const root = await lookupRoot(ctx.cwd, signal);
841
+ const root = await lookupRoot(ctx.cwd, signal, repo);
811
842
  if (params.action === "validate") {
812
843
  return await startInSession(params.id, root, signal, ctx,
813
844
  (state) => {
@@ -830,18 +861,87 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
830
861
  name: "subagent_abort",
831
862
  label: "Subagent abort",
832
863
  description: "Abort an isolated request; reject and release retained candidates and integration checkouts first unless their checkouts and branches are proved absent. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged allocations; preserve dirty or committed work.",
833
- parameters: IdOnlySchema,
834
- prepareArguments: parseIdOnly,
864
+ parameters: withRepo(IdOnlySchema),
865
+ prepareArguments: parseWithRepo(parseIdOnly),
835
866
  exposure: "model-only",
836
867
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
837
868
  latestCtx = ctx;
838
- const root = await lookupRoot(ctx.cwd, signal);
869
+ const root = await lookupRoot(ctx.cwd, signal, params.repo);
839
870
  return toolResult(await getComponents().runner.abort(params.id, root, signal), ctx, workspaceRowsByRequest);
840
871
  },
841
872
  });
873
+ /** Recovery report lines for one canonical checkout root. */
874
+ const recoveryLines = async (root: string): Promise<string[]> => {
875
+ const { requests, invalidIds, leaseBusy } = await getComponents().runner.recoverRepository(root);
876
+ const lines = [
877
+ `Pi Subagent recovery in ${root}: ${leaseBusy ? "productive lease is live; no state changed" : "orphaned interruptions classified; no work replayed"}.`,
878
+ ...(requests.length ? requests.map((response) => {
879
+ const state = response.state;
880
+ const blockers = state.tasks.flatMap((task) => [
881
+ ...(task.failure ? [`${task.taskId}: ${boundedPublicText(task.failure)}`] : []),
882
+ ...(task.kind === "changeset" && task.attempts.some((attempt) => attempt.prompts.some((prompt) => prompt.status === "ambiguous"))
883
+ ? [`${task.taskId}: ambiguous worker prompt; do not replay`] : []),
884
+ ]);
885
+ const generation = state.integration.generations.at(-1);
886
+ const readyCandidates = state.integration.candidates.filter((candidate) => candidate.worker === "retained" && !candidate.decision);
887
+ return [
888
+ `${state.request.id}: ${state.status}; Main ${response.main?.status ?? "not checked"}.`,
889
+ ...blockers.map((blocker) => ` Blocker: ${blocker}`),
890
+ ...(state.final.failure ? [` Final blocker: ${boundedPublicText(state.final.failure)}`] : []),
891
+ ...(generation?.failure ? [` Integration blocker: ${boundedPublicText(generation.failure)}`] : []),
892
+ ...(state.status !== "completed" && readyCandidates.length ? [` ${readyCandidates.length} retained candidate(s); Main selects exact candidates with subagent_stage after subagent_status.`] : []),
893
+ ...(state.status === "completed" ? [" Retained resources after completion; inspect subagent_status for guarded cleanup."] : []),
894
+ ...(generation ? [` Generation ${generation.number}: ${generation.status}; inspect subagent_status before any subagent_integrate action.`] : []),
895
+ ...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
896
+ ].join("\n");
897
+ }) : ["No unfinished or retained isolated requests."]),
898
+ ...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
899
+ leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
900
+ : "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
901
+ ];
902
+ return lines;
903
+ };
904
+ /** Report lines for discovery issues and truncation, because an incomplete search cannot prove absence. */
905
+ const discoveryWarnings = (found: RepoDiscovery): string[] => [
906
+ ...(found.truncated ? [`Repository discovery below ${found.base} stopped at a limit; some repositories were not examined.`] : []),
907
+ ...found.issues.slice(0, 20).map(({ path, reason }) => `Repository discovery could not examine ${path}: ${boundedPublicText(reason)}`),
908
+ ...(found.issues.length > 20 ? [`${found.issues.length - 20} more repository discovery issues omitted.`] : []),
909
+ ];
910
+ /** Read-only inventory across the Repository targets below a parent directory that is not a Git repository. */
911
+ const childInventory = async (cwd: string, current: () => boolean = () => true): Promise<IsolatedInventory> => {
912
+ const found = await discoverRepos(cwd);
913
+ const requests: IsolatedRequest[] = [];
914
+ const invalidIds: string[] = [];
915
+ const selectorOf = (root: string) => found.repos.find((item) => item.root === root)?.repo
916
+ ?? (isWithin(found.base, root) ? relative(found.base, root).split(sep).join("/") : root);
917
+ for (const { repo, root } of found.repos) {
918
+ if (!current()) throw new Error("Session or branch changed; reopen /subagent.");
919
+ const listed = await getComponents().runner.listRequests(root);
920
+ const leaseLive = await getComponents().runner.hasProductiveRunLease(root);
921
+ for (const request of listed.requests) requests.push({ ...request, root: request.root ?? root, repo, leaseLive, checkout: root });
922
+ invalidIds.push(...listed.invalidIds.map((id) => `${repo}/${id}`));
923
+ }
924
+ // One scan of the whole state store. An orphan's primary checkout is live, so Git commands run there.
925
+ const orphans = await getComponents().runner.listAllOrphanRequests();
926
+ for (const { primary, ...request } of orphans.requests) {
927
+ if (!isWithin(found.base, primary)) continue;
928
+ requests.push({ ...request, repo: selectorOf(primary), leaseLive: false, checkout: primary });
929
+ }
930
+ invalidIds.push(...orphans.invalidIds);
931
+ return { root: found.base, leaseLive: requests.some((request) => request.leaseLive), requests,
932
+ invalidIds: [...new Set(invalidIds)], warnings: discoveryWarnings(found) };
933
+ };
842
934
  return {
843
935
  async inventory(cwd, current) {
844
- const root = await lookupRoot(cwd);
936
+ let root: string;
937
+ try {
938
+ root = await lookupRoot(cwd);
939
+ } catch (error) {
940
+ // Outside a Git repository, list the requests of each Repository target below cwd.
941
+ // Any other failure, such as a Git timeout, stays a failure.
942
+ if (await gitTopLevel(cwd).then((top) => top !== undefined, () => true)) throw error;
943
+ return await childInventory(cwd, current);
944
+ }
845
945
  const revision = savedRevision;
846
946
  const { requests, invalidIds, states } = await getComponents().runner.listRequests(root);
847
947
  if (current?.() && !sessionClosed && revision === savedRevision) {
@@ -858,44 +958,42 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
858
958
  return { root, leaseLive, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
859
959
  },
860
960
  async recover(cwd) {
861
- const root = await lookupRoot(cwd);
862
- const { requests, invalidIds, leaseBusy } = await getComponents().runner.recoverRepository(root);
863
- const lines = [
864
- `Pi Subagent recovery in ${root}: ${leaseBusy ? "productive lease is live; no state changed" : "orphaned interruptions classified; no work replayed"}.`,
865
- ...(requests.length ? requests.map((response) => {
866
- const state = response.state;
867
- const blockers = state.tasks.flatMap((task) => [
868
- ...(task.failure ? [`${task.taskId}: ${boundedPublicText(task.failure)}`] : []),
869
- ...(task.kind === "changeset" && task.attempts.some((attempt) => attempt.prompts.some((prompt) => prompt.status === "ambiguous"))
870
- ? [`${task.taskId}: ambiguous worker prompt; do not replay`] : []),
871
- ]);
872
- const generation = state.integration.generations.at(-1);
873
- const readyCandidates = state.integration.candidates.filter((candidate) => candidate.worker === "retained" && !candidate.decision);
874
- return [
875
- `${state.request.id}: ${state.status}; Main ${response.main?.status ?? "not checked"}.`,
876
- ...blockers.map((blocker) => ` Blocker: ${blocker}`),
877
- ...(state.final.failure ? [` Final blocker: ${boundedPublicText(state.final.failure)}`] : []),
878
- ...(generation?.failure ? [` Integration blocker: ${boundedPublicText(generation.failure)}`] : []),
879
- ...(state.status !== "completed" && readyCandidates.length ? [` ${readyCandidates.length} retained candidate(s); Main selects exact candidates with subagent_stage after subagent_status.`] : []),
880
- ...(state.status === "completed" ? [" Retained resources after completion; inspect subagent_status for guarded cleanup."] : []),
881
- ...(generation ? [` Generation ${generation.number}: ${generation.status}; inspect subagent_status before any subagent_integrate action.`] : []),
882
- ...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
883
- ].join("\n");
884
- }) : ["No unfinished or retained isolated requests."]),
885
- ...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
886
- leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
887
- : "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
888
- ];
889
- return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
961
+ let root: string;
962
+ try {
963
+ root = await lookupRoot(cwd);
964
+ } catch (error) {
965
+ if (await gitTopLevel(cwd).then((top) => top !== undefined, () => true)) throw error;
966
+ // Outside a Git repository, recover each Repository target that has saved requests.
967
+ const found = await discoverRepos(cwd);
968
+ const lines: string[] = [];
969
+ for (const { repo, root: child } of found.repos) {
970
+ const listed = await getComponents().runner.listRequests(child);
971
+ if (!listed.requests.length && !listed.invalidIds.length) continue;
972
+ lines.push(`repo ${repo}:`, ...await recoveryLines(child));
973
+ }
974
+ const warnings = discoveryWarnings(found);
975
+ // An incomplete discovery is not proof that no request exists.
976
+ if (!lines.length) lines.push(warnings.length ? `No isolated requests in the ${found.repos.length} repositories that discovery examined below ${found.base}.`
977
+ : `No isolated requests in the ${found.repos.length} repositories below ${found.base}.`);
978
+ lines.push(...warnings);
979
+ return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
980
+ }
981
+ return boundedReport(await recoveryLines(root), "recovery report lines omitted; use subagent_status for exact request evidence.");
890
982
  },
891
983
  async discardPlan(cwd, request) {
892
- return await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
984
+ // A request listed from a parent directory runs Git in a live checkout of its own repository.
985
+ if (request.checkout !== undefined) cwd = request.checkout;
986
+ const plan = await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
893
987
  panes: () => listHerdrPanes(cwd), current: () => !sessionClosed,
894
988
  blocked: async () => jobOwners.has(`${request.root}\0${request.id}`) ? "A local job is active for this request." : undefined,
895
989
  });
990
+ return request.checkout === undefined ? plan : { ...plan, checkout: request.checkout };
896
991
  },
897
992
  async discard(cwd, plan, current) {
898
993
  requireCurrent(current);
994
+ // The plan keeps the live checkout that it used. The saved request root can be removed.
995
+ const checkout = (plan as DiscardPlan & { checkout?: string }).checkout;
996
+ if (checkout !== undefined) cwd = checkout;
899
997
  const removals = await getComponents().runner.discard(cwd, plan, {
900
998
  panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
901
999
  blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
@@ -937,21 +1035,25 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
937
1035
  requireCurrent(current);
938
1036
  return getComponents().runner.drainFollowups(root, requestId, taskId);
939
1037
  },
940
- async plan(request, signal, ctx) {
1038
+ async plan(request, signal, ctx, repo) {
941
1039
  latestCtx = ctx;
942
1040
  latestContext();
943
- const root = await lookupRoot(ctx.cwd, signal);
1041
+ const root = await lookupRoot(ctx.cwd, signal, repo);
944
1042
  if (jobOwners.has(`${root}\0${request.id}`)) throw new Error(`Pi Subagent request ${request.id} is already active in this session runtime.`);
945
- return formatExecutePlan(await getComponents().runner.plan(request, ctx.cwd, signal), false);
1043
+ const cwd = repo === undefined ? ctx.cwd : root;
1044
+ return formatExecutePlan(await (repo === undefined ? getComponents().runner.plan(request, cwd, signal) : getComponents().runner.plan(request, cwd, signal, { targeted: true })), false);
946
1045
  },
947
- async execute(request, signal, ctx) {
1046
+ async execute(request, signal, ctx, repo) {
948
1047
  latestCtx = ctx;
949
1048
  latestContext();
950
- const root = await lookupRoot(ctx.cwd, signal);
1049
+ const root = await lookupRoot(ctx.cwd, signal, repo);
1050
+ // The runner resolves the same root again from this cwd. A Repository target is its own root.
1051
+ const cwd = repo === undefined ? ctx.cwd : root;
951
1052
  return await startInSession(request.id, root, signal, ctx,
952
1053
  (state) => state.status === "pending" && state.createdAt === state.updatedAt
953
1054
  && state.tasks.every((task) => task.status === "pending" && task.attempts.length === 0),
954
- (runSignal) => getComponents().runner.execute(request, ctx.cwd, runSignal));
1055
+ (runSignal) => repo === undefined ? getComponents().runner.execute(request, cwd, runSignal)
1056
+ : getComponents().runner.execute(request, cwd, runSignal, { targeted: true }));
955
1057
  },
956
1058
  };
957
1059
  }
@@ -50,6 +50,12 @@ export interface IsolatedRequest {
50
50
  /** Saved state address for a removed parent checkout. */
51
51
  directory?: string;
52
52
  root?: string;
53
+ /** The Repository target selector, when /subagent runs in a parent directory of repositories. */
54
+ repo?: string;
55
+ /** The productive lease state of this request's root, when the inventory spans several roots. */
56
+ leaseLive?: boolean;
57
+ /** A live checkout of the request's repository for Git commands, when the inventory spans several roots. */
58
+ checkout?: string;
53
59
  orphaned?: boolean;
54
60
  name: string;
55
61
  status: string;
@@ -61,6 +67,8 @@ export interface IsolatedInventory {
61
67
  leaseLive: boolean;
62
68
  requests: readonly IsolatedRequest[];
63
69
  invalidIds: readonly string[];
70
+ /** Incomplete inventory evidence, such as discovery issues or truncation. */
71
+ warnings?: readonly string[];
64
72
  }
65
73
 
66
74
  export type { PruneItem };
@@ -192,7 +200,7 @@ const summary = (text: string, max = 60): string => {
192
200
  return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : chars.join("");
193
201
  };
194
202
  // Identity and status first so clipping cuts only the goal summary.
195
- const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
203
+ const isolatedLabel = ({ id, repo, status, name }: IsolatedRequest): string => `${id}${repo ? ` · repo ${repo}` : ""} · ${status} · ${summary(name)}`;
196
204
  const completed = (status: string) => [...TERMINAL_RUN_STATUSES, "success", "failed", "failure", "integrated"].includes(status);
197
205
  const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
198
206
  // Recorded-only and unverified work is uncertain, not active.
@@ -310,7 +318,7 @@ export function workingReport(direct: readonly DirectTask[], inventory: Isolated
310
318
  }
311
319
  for (const request of inventory?.requests ?? []) {
312
320
  const active = !request.orphaned && ["pending", "running"].includes(request.status);
313
- if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
321
+ if (active && (request.leaseLive ?? inventory!.leaseLive)) working.push({ text: isolatedLabel(request) });
314
322
  else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
315
323
  else if (request.status === "needs_attention" || /retained/.test(request.status)) attention.push({ text: isolatedLabel(request) });
316
324
  else if (request.orphaned && /unverified/.test(request.status)) unverified.push({ text: isolatedLabel(request) });
@@ -558,6 +566,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
558
566
  if (report) deliver(ctx, workingReport(direct, inventory));
559
567
  report = false;
560
568
  if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
569
+ for (const warning of inventory?.warnings ?? []) ctx.ui.notify(warning, "warning");
561
570
  if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
562
571
  type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "many" } | { kind: "close" };
563
572
  // A header row selects Refresh; rows sort by status priority within their group.
@@ -565,7 +574,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
565
574
  items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
566
575
  ...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
567
576
  const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
568
- const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
577
+ const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: request.root ?? inventory!.root } });
569
578
  // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
570
579
  const directChoice = (task: DirectTask): Choice<Selection> => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } });
571
580
  const groups = [
@@ -703,7 +703,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
703
703
  try {
704
704
  const parsed = parseDelegateTask(args);
705
705
  const dryRun = parsed.dryRun ? { dryRun: true } : {};
706
- if (parsed.mode === "isolated") return { ...parsed.request, ...dryRun };
706
+ if (parsed.mode === "isolated") return { ...parsed.request, ...(parsed.repo === undefined ? {} : { repo: parsed.repo }), ...dryRun };
707
707
  const workflow = parsed.workflow;
708
708
  if (workflow.mode === "single") return { mode: "direct" as const, ...workflow.delegations[0], ...dryRun };
709
709
  if (workflow.mode === "parallel") return { mode: "direct" as const, tasks: workflow.delegations, ...dryRun };
@@ -716,7 +716,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
716
716
  reportConfig(ctx);
717
717
  const parsed = parseDelegateTask(params);
718
718
  if (parsed.mode === "isolated") {
719
- return parsed.dryRun ? await isolatedSurface.plan(parsed.request, signal, ctx) : await isolatedSurface.execute(parsed.request, signal, ctx);
719
+ return parsed.dryRun ? await isolatedSurface.plan(parsed.request, signal, ctx, parsed.repo) : await isolatedSurface.execute(parsed.request, signal, ctx, parsed.repo);
720
720
  }
721
721
  const workflow = parsed.workflow;
722
722
  const catalog = loadRoleCatalog();
@@ -35,7 +35,7 @@ const ContinueFromSchema = Type.String({ pattern: "^D[1-9][0-9]*\\.[1-9][0-9]*$"
35
35
 
36
36
  const RepoSchema = Type.String({
37
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.",
38
+ description: "Optional Repository target: a child Git repository as a path relative to the Pi working directory, such as team/service. Direct workers run there, and admission reserves that checkout; a direct task value overrides the request value. Mode isolated accepts it only on the request, and the whole graph, its state, and its promotion use that repository. Omit it to use the Pi working directory. Use subagent_repos to list targets.",
39
39
  });
40
40
 
41
41
  const PathsSchema = Type.Array(Type.String({ minLength: 1 }), {
@@ -94,7 +94,8 @@ export type ParsedWorkflow =
94
94
  | { mode: "single"; delegations: [Delegation] }
95
95
  | { mode: "parallel"; delegations: Delegation[] }
96
96
  | { mode: "chain"; delegations: Delegation[] };
97
- export type ParsedDelegateTask = ({ mode: "direct"; workflow: ParsedWorkflow } | { mode: "isolated"; request: ExecuteRequest }) & { dryRun: boolean };
97
+ /** An isolated `repo` selects the one Repository target of the whole graph. It is not part of the durable request. */
98
+ export type ParsedDelegateTask = ({ mode: "direct"; workflow: ParsedWorkflow } | { mode: "isolated"; request: ExecuteRequest; repo?: string }) & { dryRun: boolean };
98
99
 
99
100
  type DirectInput = Static<typeof DirectWorkflowSchema>;
100
101
  const DELEGATION_KEYS = ["role", "name", "task", "access", "paths", "continueFrom", "kind", "model", "modelClass"] as const;
@@ -122,7 +123,7 @@ function normalizePaths(value: Static<typeof DelegationSchema>, path: string): s
122
123
  }
123
124
 
124
125
  /** Check the selector shape here. Admission and launch resolve it against the Pi working directory. */
125
- function normalizeRepo(value: string, path: string): string {
126
+ export function normalizeRepo(value: string, path: string): string {
126
127
  const normalized = text(value, path);
127
128
  if (/[\u0000-\u001f\u007f-\u009f]/.test(normalized) || normalized.startsWith("/") || normalized.split(/[\\/]/).includes("..")) {
128
129
  throw new Error(`${path} must be a relative path without control characters or .. components.`);
@@ -207,8 +208,10 @@ export function parseDelegateTask(value: unknown): ParsedDelegateTask {
207
208
  value = rest;
208
209
  }
209
210
  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.");
211
- return { mode: "isolated", request: parseExecuteRequest(value), dryRun };
211
+ const { repo, ...request } = value as Record<string, unknown>;
212
+ if (repo === undefined) return { mode: "isolated", request: parseExecuteRequest(request), dryRun };
213
+ if (typeof repo !== "string") throw new Error("repo must be a relative path.");
214
+ return { mode: "isolated", request: parseExecuteRequest(request), repo: normalizeRepo(repo, "repo"), dryRun };
212
215
  }
213
216
  return { mode: "direct", workflow: parseWorkflow(value), dryRun };
214
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hcflabs/pi-subagent",
3
- "version": "28.0.0",
3
+ "version": "28.1.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",