@hcflabs/pi-subagent 28.0.0 → 28.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/direct-herdr.d.ts +2 -0
- package/dist/direct-herdr.js +5 -0
- package/dist/herdr-runtime.d.ts +7 -1
- package/dist/herdr-runtime.js +11 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/launch-runtime.d.ts +2 -0
- package/dist/launch-runtime.js +4 -1
- package/dist/process-lease.d.ts +1 -4
- package/dist/process-lease.js +1 -42
- package/dist/runner.d.ts +26 -3
- package/dist/runner.js +20 -6
- package/dist/store.d.ts +9 -1
- package/dist/store.js +11 -4
- package/docs/orchestration.md +17 -3
- package/extensions/admission.ts +30 -1
- package/extensions/isolated.ts +177 -61
- package/extensions/subagent-command.ts +12 -3
- package/extensions/subagent.ts +116 -35
- package/extensions/workflow.ts +8 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Commands, prompts, and UI are for you. Tools and the packaged skill are for Main
|
|
|
43
43
|
| `subagent_abort` | tool | Terminate an isolated request without discarding retained work. Retained candidates and integration work require release or proof of absence. |
|
|
44
44
|
| `git_read` | tool | Let Role-declared children inspect bounded local Git evidence without Bash, tests, network access, or writes. Not available to Main. |
|
|
45
45
|
| `pi-subagent` | skill | Guide Main through authorization, delegation, checks, integration, and recovery. |
|
|
46
|
-
| Direct status widget (`D`) | ui | Show direct references, names, Role badges, status, routes, elapsed time, measured task tokens, cost, and tool activity
|
|
46
|
+
| Direct status widget (`D`) | ui | Show direct workflows as a tree under `Main` with references, names, Role badges, status, routes, elapsed time, measured task tokens, cost, and tool activity. In fullscreen mode, a click on a working row focuses that worker's verified Herdr pane. |
|
|
47
47
|
| Isolated status widget (`I`) | ui | Show recorded task state, Role/model-class badges, and retained workspaces in the TUI. Readiness does not mean promotion. |
|
|
48
48
|
|
|
49
49
|
`delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. Main must make these decisions visibly; `codemode` scripts can call only `subagent_status`.
|
|
@@ -108,7 +108,13 @@ 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
|
-
|
|
111
|
+
A `herdr_command` job with write access blocks direct writers in its checkout until `stop` proves that the job ended.
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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`.
|
|
116
|
+
|
|
117
|
+
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
118
|
|
|
113
119
|
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
120
|
|
|
@@ -273,7 +279,7 @@ The built-in reviewer answers exactly `PASS` when it finds no actionable correct
|
|
|
273
279
|
|
|
274
280
|
The built-in reviewer and scout include `git_read`; existing user reviewers and scouts must add it to `tools`. No additional extension or Bash access is necessary. See [Reviewer Git inspection](./docs/orchestration.md#reviewer-git-inspection) for operations, parameters, limits, and Git requirements.
|
|
275
281
|
|
|
276
|
-
Children load only declared resources and required adapters. Roles must list their model provider extension. `codemode` requires both `builtin:codemode` in `extensions` and `codemode` in `tools`. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work. Main-only delegation, integration, and recovery tools, `herdr_handoff`, and `ask_question` stay unavailable to children.
|
|
282
|
+
Children load only declared resources and required adapters. Roles must list their model provider extension. `codemode` requires both `builtin:codemode` in `extensions` and `codemode` in `tools`. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work. Main-only delegation, integration, and recovery tools, `herdr_handoff`, `herdr_command`, and `ask_question` stay unavailable to children.
|
|
277
283
|
|
|
278
284
|
Read [Role and resource policy](./docs/orchestration.md#role-and-resource-policy) for extension exposure, MCP validation, OAuth constraints, and child budgets. Project MCP overrides cannot change a Role's global allowlist or exposure.
|
|
279
285
|
|
package/dist/direct-herdr.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface DirectHandle {
|
|
|
20
20
|
partialText(maxChars: number): Promise<string | undefined>;
|
|
21
21
|
/** Display-only activity from the last usage() sample. */
|
|
22
22
|
readonly activity?: DirectToolActivity;
|
|
23
|
+
/** Focus the worker pane after Herdr confirms the exact launched agent still owns it. */
|
|
24
|
+
focus(): Promise<void>;
|
|
23
25
|
cancel(): Promise<void>;
|
|
24
26
|
}
|
|
25
27
|
export declare function exactDirectTerminalTurn(jsonl: string, prompt: string): boolean;
|
package/dist/direct-herdr.js
CHANGED
|
@@ -512,6 +512,11 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
512
512
|
throw new Error(`Herdr agent in tab ${tabId} became ${current}; inspect it before retrying.`);
|
|
513
513
|
return readAnswer();
|
|
514
514
|
},
|
|
515
|
+
async focus() {
|
|
516
|
+
// A reused pane ID must not receive focus: the agent identity must match the launch first.
|
|
517
|
+
inspect(await herdr.json(["agent", "get", paneId], options()), "agent_info", name, paneId, tabId);
|
|
518
|
+
await herdr.json(["agent", "focus", paneId], options());
|
|
519
|
+
},
|
|
515
520
|
async cancel() {
|
|
516
521
|
await herdr.json(["agent", "send-keys", name, "ctrl+c"], options());
|
|
517
522
|
},
|
package/dist/herdr-runtime.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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"];
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -270,7 +270,12 @@ export class HerdrHostRuntime {
|
|
|
270
270
|
this.lsofCommand = options.lsofCommand ?? "lsof";
|
|
271
271
|
this.executionBudget = options.executionBudget;
|
|
272
272
|
}
|
|
273
|
-
/**
|
|
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)
|
package/dist/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare function roleToolPolicyFromArgv(args: readonly string[]): string[
|
|
|
19
19
|
* `codemode` or `deferred` exposure. Loading `builtin:codemode` alone registers the tool inactive.
|
|
20
20
|
*/
|
|
21
21
|
export declare function roleActivatesCodemode(tools: readonly string[]): boolean;
|
|
22
|
-
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "subagent_status", "subagent_resume", "subagent_stage", "subagent_integrate", "subagent_abort", "herdr_handoff"];
|
|
22
|
+
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "subagent_status", "subagent_resume", "subagent_stage", "subagent_integrate", "subagent_abort", "herdr_handoff", "herdr_command"];
|
|
23
23
|
export declare const DELEGATE_TASK: {
|
|
24
24
|
readonly id: "pi-subagent/delegateTask";
|
|
25
25
|
readonly label: "Subagent delegation";
|
package/dist/index.js
CHANGED
|
@@ -68,6 +68,7 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
|
68
68
|
"subagent_integrate",
|
|
69
69
|
"subagent_abort",
|
|
70
70
|
"herdr_handoff",
|
|
71
|
+
"herdr_command",
|
|
71
72
|
];
|
|
72
73
|
const SYSTEM_PROMPT_FLAG = "--append-system-prompt";
|
|
73
74
|
const CHILD_IDENTITY_POLICY = "You are a delegated Pi Subagent, not Main. Execute the assigned Role and task directly. Main-only delegation rules do not apply. Recursive delegation is unavailable; do not seek or invoke delegation tools.";
|
package/dist/launch-runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/launch-runtime.js
CHANGED
|
@@ -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/process-lease.d.ts
CHANGED
|
@@ -1,5 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
export { assertPrivateLease, scanProcessLease } from "@hcflabs/pi-process";
|
|
2
2
|
export declare const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
3
|
-
export declare function assertPrivateLease(path: string, allowMissing: boolean): Promise<boolean>;
|
|
4
|
-
/** Shared exact process-lease evidence for direct and isolated workers. */
|
|
5
|
-
export declare function scanProcessLease(path: string, execute: (args: string[]) => Promise<HerdrExecResult>, pid?: number, allowMissing?: boolean): Promise<number[]>;
|
package/dist/process-lease.js
CHANGED
|
@@ -1,43 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
export { assertPrivateLease, scanProcessLease } from "@hcflabs/pi-process";
|
|
2
2
|
export const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
3
|
-
export async function assertPrivateLease(path, allowMissing) {
|
|
4
|
-
let info;
|
|
5
|
-
try {
|
|
6
|
-
info = await lstat(path);
|
|
7
|
-
}
|
|
8
|
-
catch (error) {
|
|
9
|
-
if (allowMissing && error.code === "ENOENT")
|
|
10
|
-
return false;
|
|
11
|
-
throw new Error(`Exact process lease cannot be inspected: ${path}`, { cause: error });
|
|
12
|
-
}
|
|
13
|
-
const uid = process.getuid?.();
|
|
14
|
-
if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o777) !== 0o600 || (uid !== undefined && info.uid !== uid)) {
|
|
15
|
-
throw new Error("Exact process lease must be a current-user regular non-symlink mode-0600 file.");
|
|
16
|
-
}
|
|
17
|
-
return true;
|
|
18
|
-
}
|
|
19
|
-
/** Shared exact process-lease evidence for direct and isolated workers. */
|
|
20
|
-
export async function scanProcessLease(path, execute, pid, allowMissing = false) {
|
|
21
|
-
if (!await assertPrivateLease(path, allowMissing))
|
|
22
|
-
return [];
|
|
23
|
-
// -w: host-wide warnings (for example an unreachable network mount) are not lease evidence; real errors still reach stderr.
|
|
24
|
-
const args = ["-w", "-nP", "-a", ...(pid === undefined ? [] : ["-p", String(pid)]), "-F", "p", "--", path];
|
|
25
|
-
const result = await execute(args);
|
|
26
|
-
if (result.killed || ![0, 1].includes(result.code) || (result.code === 1 && (result.stdout.trim() || result.stderr.trim()))) {
|
|
27
|
-
throw new Error("Exact process lease lsof scan failed or was ambiguous.");
|
|
28
|
-
}
|
|
29
|
-
if (result.code === 1)
|
|
30
|
-
return [];
|
|
31
|
-
if (result.stderr.trim())
|
|
32
|
-
throw new Error("Exact process lease lsof scan returned unexpected diagnostics.");
|
|
33
|
-
// lsof always selects the file descriptor field, so `-F p` also emits `f<fd>` lines.
|
|
34
|
-
const lines = result.stdout.trim().split(/\r?\n/).filter((line) => line && !/^f\w+$/.test(line));
|
|
35
|
-
if (!lines.length || lines.some((line) => !/^p[1-9]\d*$/.test(line))) {
|
|
36
|
-
throw new Error("Exact process lease lsof scan returned malformed PID fields.");
|
|
37
|
-
}
|
|
38
|
-
const holders = [...new Set(lines.map((line) => Number(line.slice(1))))];
|
|
39
|
-
if (holders.some((holder) => !Number.isSafeInteger(holder) || holder <= 0) || (pid !== undefined && holders.some((holder) => holder !== pid))) {
|
|
40
|
-
throw new Error("Exact process lease lsof scan returned an unexpected PID.");
|
|
41
|
-
}
|
|
42
|
-
return holders;
|
|
43
|
-
}
|
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
|
-
/**
|
|
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
|
-
|
|
365
|
-
|
|
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({
|
|
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
|
-
|
|
848
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
package/docs/orchestration.md
CHANGED
|
@@ -95,6 +95,8 @@ Direct writes change Main's checkout immediately. They require no clean checkout
|
|
|
95
95
|
|
|
96
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
97
|
|
|
98
|
+
A trusted tool can report long-running Main activity with two Pi events. `pi:checkout-activity:start` carries `{ jobId, cwd }`, and `pi:checkout-activity:end` carries `{ jobId }`. Admission treats an open activity as a Main call in progress in `cwd`, so a direct writer in a checkout that contains `cwd` cannot start. The activity ends only with its end event. `herdr_command` sends the end event after it proves that a write job stopped. The events coordinate one Pi session; they are not a sandbox.
|
|
99
|
+
|
|
98
100
|
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.
|
|
99
101
|
|
|
100
102
|
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.
|
|
@@ -180,7 +182,19 @@ Each direct row starts with a saved direct reference such as `D7.1`. `D7` is the
|
|
|
180
182
|
|
|
181
183
|
A session switch can move a workflow into a session that already uses its number. In that case, the workflow gets a new number there. An existing Herdr tab keeps its original launch label.
|
|
182
184
|
|
|
183
|
-
The direct widget shows the launched model, thinking level, elapsed time, measured task tokens (`— tok` until usage is available), and cost next to the tokens (such as `$0.12`; not shown when unknown or zero). Failed
|
|
185
|
+
The direct widget shows the launched model, thinking level, elapsed time, measured task tokens (`— tok` until usage is available), and cost next to the tokens (such as `$0.12`; not shown when unknown or zero). Failed workflows show before working and completed workflows. When rows overflow, the final `└─ … N more` row includes counts by status.
|
|
186
|
+
|
|
187
|
+
The direct widget is a tree under a `Main` header. The header counts the workers by status. A workflow with one worker is one row. A workflow with more than one worker gets a group row, and its launched workers are child rows:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
Main · 2 working · 1 complete
|
|
191
|
+
├─ ⠋ D7 · 2 workers
|
|
192
|
+
│ ├─ ⠋ D7.1 [S] Map entry points · …
|
|
193
|
+
│ └─ ⠋ D7.2 [S] Map tests · …
|
|
194
|
+
└─ ✓ D8.1 [R] complete · Review patch · …
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
In Pi fullscreen mode, click a working worker row to focus its Herdr pane. Before the extension moves focus, Herdr must confirm that the pane still runs the exact launched agent. If the check fails, Pi shows a warning and focus does not change. The header, group rows, finished rows, the overflow row, and rows with a truncated reference do nothing when you click them. Regular mode leaves the mouse to the terminal. Use the Herdr tab label or `/subagent` to find a worker from the keyboard. A click moves focus only. It gives no ownership or control authority.
|
|
184
198
|
|
|
185
199
|
The isolated widget shows the recorded task state and any retained workspace. It does not show reliable live model, thinking, or token readings from Herdr. Attention shows before ordinary work, and overflow uses `+N more · /subagent`. An aborted request with visible tasks has its own `request aborted` row. This row does not change the recorded statuses of the tasks.
|
|
186
200
|
|
|
@@ -467,7 +481,7 @@ Every child launch:
|
|
|
467
481
|
2. loads only declared resources plus required internal adapters;
|
|
468
482
|
3. resolves Skill and model names from Pi's effective registries;
|
|
469
483
|
4. installs an exact tool policy;
|
|
470
|
-
5. excludes Main-only delegation, integration, and recovery tools, `herdr_handoff`, and `ask_question`; and
|
|
484
|
+
5. excludes Main-only delegation, integration, and recovery tools, `herdr_handoff`, `herdr_command`, and `ask_question`; and
|
|
471
485
|
6. prepends the delegated-subagent identity contract.
|
|
472
486
|
|
|
473
487
|
An empty `tools` list adds no base tools. Selected extension tools can still activate.
|
|
@@ -496,7 +510,7 @@ Before launch, the extension checks each selected entry against Pi's documented
|
|
|
496
510
|
|
|
497
511
|
The extension never modifies the file. Fix the entry or choose another server. The extension passes valid `auth`, `oauth`, and `description` fields through, so Pi's native provider auth and OAuth apply. `oauth.clientRegistration` must be `dcr` or `cimd`. A `cimd` entry cannot use `clientId` or `clientName`. Any `callbackUrl` must use localhost or 127.0.0.1 with path `/callback`.
|
|
498
512
|
|
|
499
|
-
The extension still ignores project MCP overrides. A project cannot change the global server allowlist or exposure of the Role. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. The extension excludes Main-only delegation, integration, and recovery tools, `herdr_handoff`, and `ask_question` from children.
|
|
513
|
+
The extension still ignores project MCP overrides. A project cannot change the global server allowlist or exposure of the Role. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. The extension excludes Main-only delegation, integration, and recovery tools, `herdr_handoff`, `herdr_command`, and `ask_question` from children.
|
|
500
514
|
|
|
501
515
|
Isolated Herdr Role launches receive the same turn, token, and child-runtime budget metadata as ephemeral launches. Retained workers therefore keep one non-refilling session budget across follow-ups rather than receiving a new request budget.
|
|
502
516
|
|
package/extensions/admission.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { isWithin, resolveRepoTarget } from "@hcflabs/pi-repo";
|
|
4
5
|
|
|
@@ -24,6 +25,18 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
24
25
|
return await realpath(root);
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** A trusted tool reports a long-running Main command that can write in `cwd`. Payload: `{ jobId, cwd }`. */
|
|
29
|
+
export const CHECKOUT_ACTIVITY_START = "pi:checkout-activity:start";
|
|
30
|
+
/** The tool reports that the job's processes are proved stopped. Payload: `{ jobId }`. */
|
|
31
|
+
export const CHECKOUT_ACTIVITY_END = "pi:checkout-activity:end";
|
|
32
|
+
|
|
33
|
+
/** `herdr_command` writes only when it starts a job with write access. Read, stop, and read-access jobs do not write. */
|
|
34
|
+
function writingMainCall(toolName: string, input: unknown): boolean {
|
|
35
|
+
if (toolName !== "herdr_command") return !MAIN_NON_WRITING_TOOLS.has(toolName);
|
|
36
|
+
const { action, access } = (input ?? {}) as { action?: unknown; access?: unknown };
|
|
37
|
+
return action === "start" && access !== "read";
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/** Two scopes conflict when one contains the other. A non-Git parent directory covers its whole subtree. */
|
|
28
41
|
const overlaps = (left: string, right: string) => isWithin(left, right) || isWithin(right, left);
|
|
29
42
|
|
|
@@ -64,7 +77,7 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, classifyDirect: (inp
|
|
|
64
77
|
};
|
|
65
78
|
pi.on("tool_call", async (event, ctx) => {
|
|
66
79
|
const direct = event.toolName === "delegate_task";
|
|
67
|
-
const classified = direct ? classifyDirect(event.input) :
|
|
80
|
+
const classified = direct ? classifyDirect(event.input) : writingMainCall(event.toolName, event.input);
|
|
68
81
|
if (classified === false) return;
|
|
69
82
|
const repos: ReadonlyArray<string | undefined> = classified === true ? [undefined] : classified;
|
|
70
83
|
const call: { direct: boolean; keys?: readonly string[] } = { direct };
|
|
@@ -95,6 +108,22 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, classifyDirect: (inp
|
|
|
95
108
|
}
|
|
96
109
|
call.keys = keys;
|
|
97
110
|
});
|
|
111
|
+
// A long-running Main activity is a Main call that stays in flight after its tool result.
|
|
112
|
+
// Its key is the command's directory, so it conflicts with a writer in the checkout that contains it.
|
|
113
|
+
pi.events.on(CHECKOUT_ACTIVITY_START, (data) => {
|
|
114
|
+
const { jobId, cwd } = (data ?? {}) as { jobId?: unknown; cwd?: unknown };
|
|
115
|
+
if (typeof jobId !== "string" || !jobId || typeof cwd !== "string" || !isAbsolute(cwd)) return;
|
|
116
|
+
const id = `activity:${jobId}`;
|
|
117
|
+
heldCalls.add(id);
|
|
118
|
+
calls.set(id, { direct: false, keys: [cwd] });
|
|
119
|
+
});
|
|
120
|
+
pi.events.on(CHECKOUT_ACTIVITY_END, (data) => {
|
|
121
|
+
const { jobId } = (data ?? {}) as { jobId?: unknown };
|
|
122
|
+
if (typeof jobId !== "string") return;
|
|
123
|
+
const id = `activity:${jobId}`;
|
|
124
|
+
heldCalls.delete(id);
|
|
125
|
+
release(id);
|
|
126
|
+
});
|
|
98
127
|
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
99
128
|
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
100
129
|
pi.on("session_start", () => checkouts.clear());
|
package/extensions/isolated.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { basename, relative, sep } from "node:path";
|
|
2
3
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
3
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
import { discoverRepos, gitTopLevel, isWithin, resolveRepoTarget, type RepoDiscovery } from "@hcflabs/pi-repo";
|
|
7
|
+
import { Type, type TObject, type TProperties } from "typebox";
|
|
5
8
|
import { capEphemeralSubagentOutput, utf8Prefix, type EphemeralSubagentExecutor } from "../dist/ephemeral.js";
|
|
6
9
|
import {
|
|
7
10
|
createCanonicalGitRootResolver,
|
|
@@ -43,6 +46,7 @@ import { FileRunStore } from "../dist/store.js";
|
|
|
43
46
|
import { workerOutputForMain } from "./result-transport.ts";
|
|
44
47
|
import type { CleanupReport, IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
|
|
45
48
|
import type { DiscardPlan } from "../dist/discard.js";
|
|
49
|
+
import { normalizeRepo } from "./workflow.ts";
|
|
46
50
|
|
|
47
51
|
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
48
52
|
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
@@ -87,7 +91,7 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
87
91
|
pi,
|
|
88
92
|
context,
|
|
89
93
|
resolveRoot,
|
|
90
|
-
preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
|
|
94
|
+
preflightHost: async ({ root, targeted }, operation) => await host.preflightHost({ root, ...(targeted ? { targeted } : {}) }, operation),
|
|
91
95
|
inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
|
|
92
96
|
});
|
|
93
97
|
return {
|
|
@@ -522,10 +526,32 @@ export function formatExecutePlan(plan: ExecutePlan, hostChecked: boolean): { co
|
|
|
522
526
|
};
|
|
523
527
|
}
|
|
524
528
|
|
|
529
|
+
const RepoParameter = Type.Optional(Type.String({
|
|
530
|
+
minLength: 1,
|
|
531
|
+
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.",
|
|
532
|
+
}));
|
|
533
|
+
|
|
534
|
+
/** Add the optional `repo` selector to a flat tool schema. The selector is not part of the durable request. */
|
|
535
|
+
function withRepo<P extends TProperties>(schema: TObject<P>): TObject<P & { repo: typeof RepoParameter }> {
|
|
536
|
+
return Type.Object({ ...schema.properties, repo: RepoParameter }, { additionalProperties: false }) as unknown as TObject<P & { repo: typeof RepoParameter }>;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Parse the request without `repo`, then add `repo` back as a separate selector. */
|
|
540
|
+
function parseWithRepo<T extends object>(parse: (value: unknown) => T): (value: unknown) => T & { repo?: string } {
|
|
541
|
+
return (value) => {
|
|
542
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "repo")) return parse(value);
|
|
543
|
+
const { repo, ...rest } = value as Record<string, unknown>;
|
|
544
|
+
if (typeof repo !== "string") throw new Error("repo must be a relative path.");
|
|
545
|
+
// Normalize like delegate_task, so a recovery call selects the same Repository target.
|
|
546
|
+
return { ...parse(rest), repo: normalizeRepo(repo, "repo") };
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
525
550
|
export interface IsolatedSurface {
|
|
526
551
|
/** Dry run of execute: validates and resolves without durable state or Herdr calls. */
|
|
527
|
-
|
|
528
|
-
|
|
552
|
+
/** `repo` selects a Repository target below the Pi working directory. */
|
|
553
|
+
plan(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext, repo?: string): Promise<ReturnType<typeof formatExecutePlan>>;
|
|
554
|
+
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext, repo?: string): Promise<ReturnType<typeof toolResult>>;
|
|
529
555
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
530
556
|
recover(cwd: string): Promise<string>;
|
|
531
557
|
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
@@ -587,14 +613,20 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
587
613
|
}
|
|
588
614
|
},
|
|
589
615
|
});
|
|
590
|
-
|
|
616
|
+
/** The canonical checkout root: the Repository target `repo` below `cwd`, or the checkout that contains `cwd`. */
|
|
617
|
+
const lookupRoot = async (cwd: string, signal?: AbortSignal, repo?: string): Promise<string> => {
|
|
591
618
|
const startedAt = Date.now();
|
|
592
619
|
const context: OperationContext = {
|
|
593
620
|
signal: signal ?? new AbortController().signal,
|
|
594
621
|
timeoutMs: LOOKUP_ROOT_TIMEOUT_MS,
|
|
595
622
|
deadline: startedAt + LOOKUP_ROOT_TIMEOUT_MS,
|
|
596
623
|
};
|
|
597
|
-
return await getComponents().resolveRoot(cwd, context);
|
|
624
|
+
if (repo === undefined) return await getComponents().resolveRoot(cwd, context);
|
|
625
|
+
const target = (await resolveRepoTarget(cwd, repo, { signal: context.signal, timeoutMs: LOOKUP_ROOT_TIMEOUT_MS })).root;
|
|
626
|
+
const root = await getComponents().resolveRoot(target, context);
|
|
627
|
+
// The checkout can change between two Git probes. Use only the root that the target validation proved.
|
|
628
|
+
if (root !== target) throw new Error(`Repository target ${repo} resolved to ${target}, but Git now reports ${root}. Nothing was changed; retry.`);
|
|
629
|
+
return root;
|
|
598
630
|
};
|
|
599
631
|
|
|
600
632
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -756,12 +788,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
756
788
|
name: "subagent_status",
|
|
757
789
|
label: "Subagent status",
|
|
758
790
|
description: "Read one durable isolated request without reconciling or changing resources.",
|
|
759
|
-
parameters: IdOnlySchema,
|
|
760
|
-
prepareArguments: parseIdOnly,
|
|
791
|
+
parameters: withRepo(IdOnlySchema),
|
|
792
|
+
prepareArguments: parseWithRepo(parseIdOnly),
|
|
761
793
|
outputSchema: StatusOutputSchema,
|
|
762
794
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
763
795
|
latestCtx = ctx;
|
|
764
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
796
|
+
const root = await lookupRoot(ctx.cwd, signal, params.repo);
|
|
765
797
|
const response = await getComponents().runner.status(params.id, root, signal);
|
|
766
798
|
const result = toolResult(response, ctx, workspaceRowsByRequest);
|
|
767
799
|
const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
|
|
@@ -773,12 +805,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
773
805
|
name: "subagent_resume",
|
|
774
806
|
label: "Subagent resume",
|
|
775
807
|
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,
|
|
808
|
+
parameters: withRepo(ResumeRequestParameters),
|
|
809
|
+
prepareArguments: parseWithRepo(parseResumeRequest),
|
|
778
810
|
exposure: "model-only",
|
|
779
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
811
|
+
async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
|
|
780
812
|
latestCtx = ctx;
|
|
781
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
813
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
782
814
|
return await startInSession(params.id, root, signal, ctx,
|
|
783
815
|
(state) => state.recovery?.kind === "resume" && state.recovery.action === params.action
|
|
784
816
|
&& (!("taskId" in params) || state.recovery.taskId === params.taskId),
|
|
@@ -789,12 +821,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
789
821
|
name: "subagent_stage",
|
|
790
822
|
label: "Subagent stage",
|
|
791
823
|
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,
|
|
824
|
+
parameters: withRepo(StageRequestSchema),
|
|
825
|
+
prepareArguments: parseWithRepo(parseStageRequest),
|
|
794
826
|
exposure: "model-only",
|
|
795
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
827
|
+
async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
|
|
796
828
|
latestCtx = ctx;
|
|
797
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
829
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
798
830
|
return toolResult(await getComponents().runner.stage(params, root, signal), ctx, workspaceRowsByRequest);
|
|
799
831
|
},
|
|
800
832
|
});
|
|
@@ -802,12 +834,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
802
834
|
name: "subagent_integrate",
|
|
803
835
|
label: "Subagent integrate",
|
|
804
836
|
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,
|
|
837
|
+
parameters: withRepo(IntegrationActionParameters),
|
|
838
|
+
prepareArguments: parseWithRepo(parseIntegrationAction),
|
|
807
839
|
exposure: "model-only",
|
|
808
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
840
|
+
async execute(_toolCallId, { repo, ...params }, signal, _onUpdate, ctx) {
|
|
809
841
|
latestCtx = ctx;
|
|
810
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
842
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
811
843
|
if (params.action === "validate") {
|
|
812
844
|
return await startInSession(params.id, root, signal, ctx,
|
|
813
845
|
(state) => {
|
|
@@ -830,18 +862,97 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
830
862
|
name: "subagent_abort",
|
|
831
863
|
label: "Subagent abort",
|
|
832
864
|
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,
|
|
865
|
+
parameters: withRepo(IdOnlySchema),
|
|
866
|
+
prepareArguments: parseWithRepo(parseIdOnly),
|
|
835
867
|
exposure: "model-only",
|
|
836
868
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
837
869
|
latestCtx = ctx;
|
|
838
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
870
|
+
const root = await lookupRoot(ctx.cwd, signal, params.repo);
|
|
839
871
|
return toolResult(await getComponents().runner.abort(params.id, root, signal), ctx, workspaceRowsByRequest);
|
|
840
872
|
},
|
|
841
873
|
});
|
|
874
|
+
/** Recovery report lines for one canonical checkout root. */
|
|
875
|
+
const recoveryLines = async (root: string): Promise<string[]> => {
|
|
876
|
+
const { requests, invalidIds, leaseBusy } = await getComponents().runner.recoverRepository(root);
|
|
877
|
+
const lines = [
|
|
878
|
+
`Pi Subagent recovery in ${root}: ${leaseBusy ? "productive lease is live; no state changed" : "orphaned interruptions classified; no work replayed"}.`,
|
|
879
|
+
...(requests.length ? requests.map((response) => {
|
|
880
|
+
const state = response.state;
|
|
881
|
+
const blockers = state.tasks.flatMap((task) => [
|
|
882
|
+
...(task.failure ? [`${task.taskId}: ${boundedPublicText(task.failure)}`] : []),
|
|
883
|
+
...(task.kind === "changeset" && task.attempts.some((attempt) => attempt.prompts.some((prompt) => prompt.status === "ambiguous"))
|
|
884
|
+
? [`${task.taskId}: ambiguous worker prompt; do not replay`] : []),
|
|
885
|
+
]);
|
|
886
|
+
const generation = state.integration.generations.at(-1);
|
|
887
|
+
const readyCandidates = state.integration.candidates.filter((candidate) => candidate.worker === "retained" && !candidate.decision);
|
|
888
|
+
return [
|
|
889
|
+
`${state.request.id}: ${state.status}; Main ${response.main?.status ?? "not checked"}.`,
|
|
890
|
+
...blockers.map((blocker) => ` Blocker: ${blocker}`),
|
|
891
|
+
...(state.final.failure ? [` Final blocker: ${boundedPublicText(state.final.failure)}`] : []),
|
|
892
|
+
...(generation?.failure ? [` Integration blocker: ${boundedPublicText(generation.failure)}`] : []),
|
|
893
|
+
...(state.status !== "completed" && readyCandidates.length ? [` ${readyCandidates.length} retained candidate(s); Main selects exact candidates with subagent_stage after subagent_status.`] : []),
|
|
894
|
+
...(state.status === "completed" ? [" Retained resources after completion; inspect subagent_status for guarded cleanup."] : []),
|
|
895
|
+
...(generation ? [` Generation ${generation.number}: ${generation.status}; inspect subagent_status before any subagent_integrate action.`] : []),
|
|
896
|
+
...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
|
|
897
|
+
].join("\n");
|
|
898
|
+
}) : ["No unfinished or retained isolated requests."]),
|
|
899
|
+
...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
|
|
900
|
+
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
901
|
+
: "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.",
|
|
902
|
+
];
|
|
903
|
+
return lines;
|
|
904
|
+
};
|
|
905
|
+
/** Report lines for discovery issues and truncation, because an incomplete search cannot prove absence. */
|
|
906
|
+
const discoveryWarnings = (found: RepoDiscovery): string[] => [
|
|
907
|
+
...(found.truncated ? [`Repository discovery below ${found.base} stopped at a limit; some repositories were not examined.`] : []),
|
|
908
|
+
...found.issues.slice(0, 20).map(({ path, reason }) => `Repository discovery could not examine ${path}: ${boundedPublicText(reason)}`),
|
|
909
|
+
...(found.issues.length > 20 ? [`${found.issues.length - 20} more repository discovery issues omitted.`] : []),
|
|
910
|
+
];
|
|
911
|
+
/** Read-only inventory across the Repository targets below a parent directory that is not a Git repository. */
|
|
912
|
+
const childInventory = async (cwd: string, current: () => boolean = () => true): Promise<IsolatedInventory> => {
|
|
913
|
+
const found = await discoverRepos(cwd);
|
|
914
|
+
const requests: IsolatedRequest[] = [];
|
|
915
|
+
const invalidIds: string[] = [];
|
|
916
|
+
const selectorOf = (root: string) => found.repos.find((item) => item.root === root)?.repo
|
|
917
|
+
?? (isWithin(found.base, root) ? relative(found.base, root).split(sep).join("/") : root);
|
|
918
|
+
const warnings = discoveryWarnings(found);
|
|
919
|
+
for (const { repo, root } of found.repos) {
|
|
920
|
+
if (!current()) throw new Error("Session or branch changed; reopen /subagent.");
|
|
921
|
+
let listed: Awaited<ReturnType<IsolatedRunner["listRequests"]>>;
|
|
922
|
+
let leaseLive: boolean;
|
|
923
|
+
try {
|
|
924
|
+
listed = await getComponents().runner.listRequests(root);
|
|
925
|
+
leaseLive = await getComponents().runner.hasProductiveRunLease(root);
|
|
926
|
+
} catch (error) {
|
|
927
|
+
// A repository removed after discovery has no requests to list. Other failures stay visible.
|
|
928
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && await lstat(root).then(() => false, () => true)) continue;
|
|
929
|
+
warnings.push(`Isolated inventory could not read repo ${repo}: ${boundedPublicText(error instanceof Error ? error.message : String(error))}`);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
for (const request of listed.requests) requests.push({ ...request, root: request.root ?? root, repo, leaseLive, checkout: root });
|
|
933
|
+
invalidIds.push(...listed.invalidIds.map((id) => `${repo}/${id}`));
|
|
934
|
+
}
|
|
935
|
+
// One scan of the whole state store. An orphan's primary checkout is live, so Git commands run there.
|
|
936
|
+
const orphans = await getComponents().runner.listAllOrphanRequests();
|
|
937
|
+
for (const { primary, ...request } of orphans.requests) {
|
|
938
|
+
if (!isWithin(found.base, primary)) continue;
|
|
939
|
+
requests.push({ ...request, repo: selectorOf(primary), leaseLive: false, checkout: primary });
|
|
940
|
+
}
|
|
941
|
+
invalidIds.push(...orphans.invalidIds);
|
|
942
|
+
return { root: found.base, leaseLive: requests.some((request) => request.leaseLive), requests,
|
|
943
|
+
invalidIds: [...new Set(invalidIds)], warnings };
|
|
944
|
+
};
|
|
842
945
|
return {
|
|
843
946
|
async inventory(cwd, current) {
|
|
844
|
-
|
|
947
|
+
let root: string;
|
|
948
|
+
try {
|
|
949
|
+
root = await lookupRoot(cwd);
|
|
950
|
+
} catch (error) {
|
|
951
|
+
// Outside a Git repository, list the requests of each Repository target below cwd.
|
|
952
|
+
// Any other failure, such as a Git timeout, stays a failure.
|
|
953
|
+
if (await gitTopLevel(cwd).then((top) => top !== undefined, () => true)) throw error;
|
|
954
|
+
return await childInventory(cwd, current);
|
|
955
|
+
}
|
|
845
956
|
const revision = savedRevision;
|
|
846
957
|
const { requests, invalidIds, states } = await getComponents().runner.listRequests(root);
|
|
847
958
|
if (current?.() && !sessionClosed && revision === savedRevision) {
|
|
@@ -858,44 +969,45 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
858
969
|
return { root, leaseLive, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
|
|
859
970
|
},
|
|
860
971
|
async recover(cwd) {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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.");
|
|
972
|
+
let root: string;
|
|
973
|
+
try {
|
|
974
|
+
root = await lookupRoot(cwd);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
if (await gitTopLevel(cwd).then((top) => top !== undefined, () => true)) throw error;
|
|
977
|
+
// Outside a Git repository, recover each Repository target that has saved requests.
|
|
978
|
+
const found = await discoverRepos(cwd);
|
|
979
|
+
const lines: string[] = [];
|
|
980
|
+
for (const { repo, root: child } of found.repos) {
|
|
981
|
+
const listed = await getComponents().runner.listRequests(child).catch(async (error: unknown) => {
|
|
982
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && await lstat(child).then(() => false, () => true)) return { requests: [], invalidIds: [] };
|
|
983
|
+
throw error;
|
|
984
|
+
});
|
|
985
|
+
if (!listed.requests.length && !listed.invalidIds.length) continue;
|
|
986
|
+
lines.push(`repo ${repo}:`, ...await recoveryLines(child));
|
|
987
|
+
}
|
|
988
|
+
const warnings = discoveryWarnings(found);
|
|
989
|
+
// An incomplete discovery is not proof that no request exists.
|
|
990
|
+
if (!lines.length) lines.push(warnings.length ? `No isolated requests in the ${found.repos.length} repositories that discovery examined below ${found.base}.`
|
|
991
|
+
: `No isolated requests in the ${found.repos.length} repositories below ${found.base}.`);
|
|
992
|
+
lines.push(...warnings);
|
|
993
|
+
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
994
|
+
}
|
|
995
|
+
return boundedReport(await recoveryLines(root), "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
890
996
|
},
|
|
891
997
|
async discardPlan(cwd, request) {
|
|
892
|
-
|
|
998
|
+
// A request listed from a parent directory runs Git in a live checkout of its own repository.
|
|
999
|
+
if (request.checkout !== undefined) cwd = request.checkout;
|
|
1000
|
+
const plan = await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
|
|
893
1001
|
panes: () => listHerdrPanes(cwd), current: () => !sessionClosed,
|
|
894
1002
|
blocked: async () => jobOwners.has(`${request.root}\0${request.id}`) ? "A local job is active for this request." : undefined,
|
|
895
1003
|
});
|
|
1004
|
+
return request.checkout === undefined ? plan : { ...plan, checkout: request.checkout };
|
|
896
1005
|
},
|
|
897
1006
|
async discard(cwd, plan, current) {
|
|
898
1007
|
requireCurrent(current);
|
|
1008
|
+
// The plan keeps the live checkout that it used. The saved request root can be removed.
|
|
1009
|
+
const checkout = (plan as DiscardPlan & { checkout?: string }).checkout;
|
|
1010
|
+
if (checkout !== undefined) cwd = checkout;
|
|
899
1011
|
const removals = await getComponents().runner.discard(cwd, plan, {
|
|
900
1012
|
panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
|
|
901
1013
|
blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
|
|
@@ -937,21 +1049,25 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
937
1049
|
requireCurrent(current);
|
|
938
1050
|
return getComponents().runner.drainFollowups(root, requestId, taskId);
|
|
939
1051
|
},
|
|
940
|
-
async plan(request, signal, ctx) {
|
|
1052
|
+
async plan(request, signal, ctx, repo) {
|
|
941
1053
|
latestCtx = ctx;
|
|
942
1054
|
latestContext();
|
|
943
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
1055
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
944
1056
|
if (jobOwners.has(`${root}\0${request.id}`)) throw new Error(`Pi Subagent request ${request.id} is already active in this session runtime.`);
|
|
945
|
-
|
|
1057
|
+
const cwd = repo === undefined ? ctx.cwd : root;
|
|
1058
|
+
return formatExecutePlan(await (repo === undefined ? getComponents().runner.plan(request, cwd, signal) : getComponents().runner.plan(request, cwd, signal, { targeted: true })), false);
|
|
946
1059
|
},
|
|
947
|
-
async execute(request, signal, ctx) {
|
|
1060
|
+
async execute(request, signal, ctx, repo) {
|
|
948
1061
|
latestCtx = ctx;
|
|
949
1062
|
latestContext();
|
|
950
|
-
const root = await lookupRoot(ctx.cwd, signal);
|
|
1063
|
+
const root = await lookupRoot(ctx.cwd, signal, repo);
|
|
1064
|
+
// The runner resolves the same root again from this cwd. A Repository target is its own root.
|
|
1065
|
+
const cwd = repo === undefined ? ctx.cwd : root;
|
|
951
1066
|
return await startInSession(request.id, root, signal, ctx,
|
|
952
1067
|
(state) => state.status === "pending" && state.createdAt === state.updatedAt
|
|
953
1068
|
&& state.tasks.every((task) => task.status === "pending" && task.attempts.length === 0),
|
|
954
|
-
(runSignal) => getComponents().runner.execute(request,
|
|
1069
|
+
(runSignal) => repo === undefined ? getComponents().runner.execute(request, cwd, runSignal)
|
|
1070
|
+
: getComponents().runner.execute(request, cwd, runSignal, { targeted: true }));
|
|
955
1071
|
},
|
|
956
1072
|
};
|
|
957
1073
|
}
|
|
@@ -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 = [
|
package/extensions/subagent.ts
CHANGED
|
@@ -24,6 +24,10 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
|
|
|
24
24
|
type WidgetStatus = "working" | "success" | "failure";
|
|
25
25
|
type WidgetItem = {
|
|
26
26
|
ref: string;
|
|
27
|
+
/** Display numbers only; they group rows and never identify a pane. */
|
|
28
|
+
workflow: number;
|
|
29
|
+
worker: number;
|
|
30
|
+
workers: number;
|
|
27
31
|
role: string;
|
|
28
32
|
model: string;
|
|
29
33
|
thinkingLevel: string;
|
|
@@ -76,45 +80,88 @@ function activityLabel(activity: DirectToolActivity | undefined): string | undef
|
|
|
76
80
|
return parts.length ? parts.join(" · ") : undefined;
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
function renderWorkerRow(item: WidgetItem, width: number, now: number, spinnerIndex: number, theme: Theme): string {
|
|
84
|
+
const prefix = `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", `${item.ref} ${item.role}`)} ${item.status === "working" ? "" : `${statusLabel(item.status)} · `}`;
|
|
85
|
+
const metrics = [
|
|
86
|
+
`${item.model}/${item.thinkingLevel}`,
|
|
87
|
+
tokenLabel(item.tokens, item.cost),
|
|
88
|
+
formatDuration((item.finishedAt ?? now) - item.startedAt),
|
|
89
|
+
];
|
|
90
|
+
const activity = activityLabel(item.activity);
|
|
91
|
+
if (activity) metrics.push(activity); // Activity is the first metric removed on narrow rows.
|
|
92
|
+
while (metrics.length && width - visibleWidth(prefix) - visibleWidth(` · ${metrics.join(" · ")}`) < 8) {
|
|
93
|
+
if (metrics.length === 2) metrics.shift(); // Preserve measured tokens ahead of route details.
|
|
94
|
+
else metrics.pop();
|
|
95
|
+
}
|
|
96
|
+
const suffix = metrics.length ? ` · ${theme.fg("muted", metrics.join(" · "))}` : "";
|
|
97
|
+
const name = truncateToWidth(theme.fg("text", item.name), Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix)));
|
|
98
|
+
return truncateToWidth(`${prefix}${name}${suffix}`, width);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const STATUS_RANK: Record<WidgetStatus, number> = { failure: 0, working: 1, success: 2 };
|
|
102
|
+
const STATUS_COUNT_ORDER = ["working", "failure", "success"] as const;
|
|
103
|
+
|
|
104
|
+
function statusCounts(items: readonly WidgetItem[]): string {
|
|
105
|
+
const counts: Record<WidgetStatus, number> = { working: 0, success: 0, failure: 0 };
|
|
106
|
+
for (const { status } of items) counts[status] += 1;
|
|
107
|
+
return STATUS_COUNT_ORDER.flatMap((status) => counts[status] ? [`${counts[status]} ${statusLabel(status)}`] : []).join(" · ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One rendered widget line. `focus` names a live worker item that a click may focus. */
|
|
111
|
+
type WidgetRow = { text: string; focus?: string };
|
|
112
|
+
type TreeLine = { top: number; child?: { last: boolean }; item?: [string, WidgetItem]; group?: WidgetItem[] };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render the direct widget as a tree under a Main header.
|
|
116
|
+
* A workflow with one launched worker is a leaf row. A workflow with more workers gets a group row.
|
|
117
|
+
* `focusable` tells which item IDs have a live handle. Only fully visible references become click targets.
|
|
118
|
+
*/
|
|
79
119
|
function renderWidgetRows(
|
|
80
|
-
items: WidgetItem
|
|
120
|
+
items: ReadonlyArray<[string, WidgetItem]>,
|
|
81
121
|
width: number,
|
|
82
122
|
now: number,
|
|
83
123
|
spinnerIndex: number,
|
|
84
124
|
theme: Theme,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
if (activity) metrics.push(activity); // Activity is the first metric removed on narrow rows.
|
|
99
|
-
while (metrics.length && width - visibleWidth(prefix) - visibleWidth(` · ${metrics.join(" · ")}`) < 8) {
|
|
100
|
-
if (metrics.length === 2) metrics.shift(); // Preserve measured tokens ahead of route details.
|
|
101
|
-
else metrics.pop();
|
|
102
|
-
}
|
|
103
|
-
const suffix = metrics.length ? ` · ${theme.fg("muted", metrics.join(" · "))}` : "";
|
|
104
|
-
const name = truncateToWidth(theme.fg("text", item.name), Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix)));
|
|
105
|
-
return truncateToWidth(`${prefix}${name}${suffix}`, width);
|
|
125
|
+
focusable: (id: string) => boolean = () => false,
|
|
126
|
+
): WidgetRow[] {
|
|
127
|
+
if (!items.length) return [];
|
|
128
|
+
const groups = new Map<number, Array<[string, WidgetItem]>>();
|
|
129
|
+
for (const entry of items) groups.set(entry[1].workflow, [...groups.get(entry[1].workflow) ?? [], entry]);
|
|
130
|
+
const rank = (list: ReadonlyArray<[string, WidgetItem]>) => Math.min(...list.map(([, item]) => STATUS_RANK[item.status]));
|
|
131
|
+
const ordered = [...groups].sort(([a, left], [b, right]) => rank(left) - rank(right) || a - b)
|
|
132
|
+
.map(([, list]) => list.sort(([, a], [, b]) => a.worker - b.worker));
|
|
133
|
+
const lines: TreeLine[] = [];
|
|
134
|
+
ordered.forEach((list, top) => {
|
|
135
|
+
if (list.length === 1 && list[0]![1].workers === 1) return void lines.push({ top, item: list[0]! });
|
|
136
|
+
lines.push({ top, group: list.map(([, item]) => item) });
|
|
137
|
+
list.forEach((entry, index) => lines.push({ top, child: { last: index === list.length - 1 }, item: entry }));
|
|
106
138
|
});
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
139
|
+
const visible = lines.length > MAX_WIDGET_LINES ? lines.slice(0, MAX_WIDGET_LINES - 1) : lines;
|
|
140
|
+
const hidden = lines.slice(visible.length).flatMap(({ item }) => item ? [item[1]] : []);
|
|
141
|
+
const lastTop = hidden.length ? -1 : visible.at(-1)!.top;
|
|
142
|
+
const rows: WidgetRow[] = [{ text: truncateToWidth(`${theme.fg("accent", "Main")}${theme.fg("muted", ` · ${statusCounts(items.map(([, item]) => item))}`)}`, width) }];
|
|
143
|
+
for (const line of visible) {
|
|
144
|
+
const topLast = line.top === lastTop;
|
|
145
|
+
const connector = line.child
|
|
146
|
+
? `${topLast ? " " : "│ "}${line.child.last ? "└─ " : "├─ "}`
|
|
147
|
+
: topLast ? "└─ " : "├─ ";
|
|
148
|
+
const branch = theme.fg("dim", connector);
|
|
149
|
+
const room = Math.max(0, width - visibleWidth(connector));
|
|
150
|
+
if (line.group) {
|
|
151
|
+
const status = line.group.reduce<WidgetStatus>((best, { status }) => STATUS_RANK[status] < STATUS_RANK[best] ? status : best, "success");
|
|
152
|
+
const first = line.group[0]!;
|
|
153
|
+
const label = `${statusGlyph(status, spinnerIndex, theme)} ${theme.fg("accent", `D${first.workflow}`)}${theme.fg("muted", ` · ${first.workers} workers`)}`;
|
|
154
|
+
rows.push({ text: truncateToWidth(`${branch}${truncateToWidth(label, room)}`, width) });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const [id, item] = line.item!;
|
|
158
|
+
const text = truncateToWidth(`${branch}${renderWorkerRow(item, room, now, spinnerIndex, theme)}`, width);
|
|
159
|
+
// The glyph, a space, and the full reference must be visible before a click can name this worker.
|
|
160
|
+
const referenceVisible = visibleWidth(connector) + 2 + visibleWidth(item.ref) <= width;
|
|
161
|
+
rows.push({ text, ...(item.status === "working" && referenceVisible && focusable(id) ? { focus: id } : {}) });
|
|
116
162
|
}
|
|
117
|
-
|
|
163
|
+
if (hidden.length) rows.push({ text: truncateToWidth(`${theme.fg("dim", "└─ ")}${theme.fg("muted", `… ${hidden.length} more · ${statusCounts(hidden)}`)}`, width) });
|
|
164
|
+
return rows;
|
|
118
165
|
}
|
|
119
166
|
|
|
120
167
|
function replaceRouteModel(ctx: ExtensionContext, reference: string, route: ResolvedTaskRoute): ResolvedTaskRoute {
|
|
@@ -460,14 +507,45 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
460
507
|
widgetTimer.unref();
|
|
461
508
|
};
|
|
462
509
|
|
|
510
|
+
let focusInFlight = false;
|
|
511
|
+
// A click is a request only. The handle checks the exact Herdr agent identity before it moves focus.
|
|
512
|
+
const focusWorker = (id: string) => {
|
|
513
|
+
const handle = tokenHandles.get(id);
|
|
514
|
+
const item = widgetItems.get(id);
|
|
515
|
+
if (!handle || item?.status !== "working" || focusInFlight) return;
|
|
516
|
+
const epoch = sessionEpoch;
|
|
517
|
+
const ctx = latestCtx;
|
|
518
|
+
focusInFlight = true;
|
|
519
|
+
void handle.focus().catch((error: unknown) => {
|
|
520
|
+
if (epoch === sessionEpoch && ctx?.hasUI) {
|
|
521
|
+
ctx.ui.notify(`Cannot focus ${item.ref}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
522
|
+
}
|
|
523
|
+
}).finally(() => { focusInFlight = false; });
|
|
524
|
+
};
|
|
525
|
+
|
|
463
526
|
const ensureWidget = (ctx: ExtensionContext) => {
|
|
464
527
|
if (!ctx.hasUI || widgetInstalled) return;
|
|
465
528
|
widgetInstalled = true;
|
|
466
529
|
ctx.ui.setWidget(WIDGET_KEY, (tui, theme): Component => {
|
|
467
530
|
activeTui = tui;
|
|
531
|
+
let renderedWidth = -1;
|
|
532
|
+
let hits: Array<{ row: number; to: number; id: string }> = [];
|
|
468
533
|
return {
|
|
469
534
|
invalidate() {},
|
|
470
|
-
render
|
|
535
|
+
render(width) {
|
|
536
|
+
const rows = renderWidgetRows([...widgetItems], width, Date.now(), spinnerIndex, theme, (id) => tokenHandles.has(id));
|
|
537
|
+
renderedWidth = width;
|
|
538
|
+
hits = rows.flatMap(({ text, focus }, row) => focus ? [{ row, to: visibleWidth(text), id: focus }] : []);
|
|
539
|
+
return rows.map(({ text }) => text);
|
|
540
|
+
},
|
|
541
|
+
// Pi routes mouse events only in fullscreen mode. Regular mode keeps terminal selection.
|
|
542
|
+
handleMouse(event) {
|
|
543
|
+
if (event.type !== "click" || event.button !== "left" || event.width !== renderedWidth) return undefined;
|
|
544
|
+
const hit = hits.find(({ row, to }) => row === event.y && event.x >= 0 && event.x < to);
|
|
545
|
+
if (!hit) return undefined;
|
|
546
|
+
focusWorker(hit.id);
|
|
547
|
+
return { handled: true, render: false };
|
|
548
|
+
},
|
|
471
549
|
};
|
|
472
550
|
});
|
|
473
551
|
};
|
|
@@ -491,6 +569,9 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
491
569
|
}
|
|
492
570
|
widgetItems.set(id, {
|
|
493
571
|
ref: directRef(display),
|
|
572
|
+
workflow: display.workflow,
|
|
573
|
+
worker: display.worker,
|
|
574
|
+
workers: display.workers,
|
|
494
575
|
role: roleBadge(display.role),
|
|
495
576
|
model,
|
|
496
577
|
thinkingLevel: thinkingLevel ?? "default",
|
|
@@ -703,7 +784,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
703
784
|
try {
|
|
704
785
|
const parsed = parseDelegateTask(args);
|
|
705
786
|
const dryRun = parsed.dryRun ? { dryRun: true } : {};
|
|
706
|
-
if (parsed.mode === "isolated") return { ...parsed.request, ...dryRun };
|
|
787
|
+
if (parsed.mode === "isolated") return { ...parsed.request, ...(parsed.repo === undefined ? {} : { repo: parsed.repo }), ...dryRun };
|
|
707
788
|
const workflow = parsed.workflow;
|
|
708
789
|
if (workflow.mode === "single") return { mode: "direct" as const, ...workflow.delegations[0], ...dryRun };
|
|
709
790
|
if (workflow.mode === "parallel") return { mode: "direct" as const, tasks: workflow.delegations, ...dryRun };
|
|
@@ -716,7 +797,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
716
797
|
reportConfig(ctx);
|
|
717
798
|
const parsed = parseDelegateTask(params);
|
|
718
799
|
if (parsed.mode === "isolated") {
|
|
719
|
-
return parsed.dryRun ? await isolatedSurface.plan(parsed.request, signal, ctx) : await isolatedSurface.execute(parsed.request, signal, ctx);
|
|
800
|
+
return parsed.dryRun ? await isolatedSurface.plan(parsed.request, signal, ctx, parsed.repo) : await isolatedSurface.execute(parsed.request, signal, ctx, parsed.repo);
|
|
720
801
|
}
|
|
721
802
|
const workflow = parsed.workflow;
|
|
722
803
|
const catalog = loadRoleCatalog();
|
package/extensions/workflow.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
return { mode: "isolated", request: parseExecuteRequest(
|
|
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.
|
|
3
|
+
"version": "28.2.1",
|
|
4
4
|
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@hcflabs/pi-config-store": "^4.0.0",
|
|
74
|
-
"@hcflabs/pi-herdr": "^0.
|
|
74
|
+
"@hcflabs/pi-herdr": "^0.6.0",
|
|
75
75
|
"@hcflabs/pi-multi-codex": "^3.0.0",
|
|
76
|
-
"@hcflabs/pi-process": "^0.
|
|
76
|
+
"@hcflabs/pi-process": "^0.3.0",
|
|
77
77
|
"@hcflabs/pi-repo": "^0.1.0",
|
|
78
78
|
"@hcflabs/pi-task-models": "^9.0.0",
|
|
79
79
|
"proper-lockfile": "^4.1.2"
|