@hcflabs/pi-subagent 28.1.0 → 28.2.2
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 +4 -2
- package/dist/direct-herdr.d.ts +2 -0
- package/dist/direct-herdr.js +62 -25
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/process-lease.d.ts +1 -4
- package/dist/process-lease.js +1 -42
- package/docs/orchestration.md +19 -3
- package/extensions/admission.ts +30 -1
- package/extensions/isolated.ts +18 -4
- package/extensions/subagent.ts +114 -33
- 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,6 +108,8 @@ 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
|
+
A `herdr_command` job with write access blocks direct writers in its checkout until `stop` proves that the job ended.
|
|
112
|
+
|
|
111
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.
|
|
112
114
|
|
|
113
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`.
|
|
@@ -277,7 +279,7 @@ The built-in reviewer answers exactly `PASS` when it finds no actionable correct
|
|
|
277
279
|
|
|
278
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.
|
|
279
281
|
|
|
280
|
-
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.
|
|
281
283
|
|
|
282
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.
|
|
283
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
|
@@ -86,6 +86,9 @@ export function exactDirectTerminalTurn(jsonl, prompt) {
|
|
|
86
86
|
return reason === "stop" || reason === "error" || reason === "aborted";
|
|
87
87
|
}
|
|
88
88
|
const turnSuffix = (turn) => `\n\nTurn identity: ${turn}`;
|
|
89
|
+
/** How long to wait for Pi to retry a turn that ended with a provider error, and how many times. */
|
|
90
|
+
const PROVIDER_RETRY_GRACE_MS = 65_000;
|
|
91
|
+
const PROVIDER_RETRY_WAITS = 5;
|
|
89
92
|
/** Refuse continuation unless the saved session's exact recorded turn is terminal and no later user turn exists. */
|
|
90
93
|
export async function assertSettledDirectSession(sessionFile, turn) {
|
|
91
94
|
const info = await stat(sessionFile).catch((error) => {
|
|
@@ -481,36 +484,70 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
481
484
|
deadline = Date.now() + idleMs;
|
|
482
485
|
}
|
|
483
486
|
};
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
"--until", "done", "--until", "blocked", "--until", "unknown",
|
|
498
|
-
"--timeout", String(Math.max(1, Math.min(current === "idle" ? 1000 : OPERATION_MS - 1000, remaining)))];
|
|
487
|
+
let retryWaits = 0;
|
|
488
|
+
// A provider error, such as HTTP 429, ends a turn with stopReason "error", and Pi can then retry it.
|
|
489
|
+
// Wait a bounded time for the retry before the error counts as the final answer.
|
|
490
|
+
const retryPending = async () => {
|
|
491
|
+
if (retryWaits >= PROVIDER_RETRY_WAITS)
|
|
492
|
+
return false;
|
|
493
|
+
const info = await stat(sessionFile).catch(() => undefined);
|
|
494
|
+
if (!info || info.size > SESSION_LIMIT)
|
|
495
|
+
return false;
|
|
496
|
+
if (exactFinalTurn(await readFile(sessionFile, "utf8"), prompt)?.stopReason !== "error")
|
|
497
|
+
return false;
|
|
498
|
+
retryWaits += 1;
|
|
499
|
+
const args = ["agent", "wait", name, "--until", "working", "--timeout", String(PROVIDER_RETRY_GRACE_MS)];
|
|
499
500
|
const waited = await herdr.exec(args, options(signal));
|
|
500
501
|
if (waited.code !== 0 || waited.killed) {
|
|
501
|
-
if (!waited.killed && hasHerdrErrorCode(waited, "timeout"))
|
|
502
|
-
|
|
503
|
-
}
|
|
502
|
+
if (!waited.killed && hasHerdrErrorCode(waited, "timeout"))
|
|
503
|
+
return false;
|
|
504
504
|
throw new Error(herdrCommandFailure(args, waited));
|
|
505
505
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
506
|
+
current = inspect(JSON.parse(waited.stdout), "agent_info", name, paneId, tabId);
|
|
507
|
+
deadline = Date.now() + idleMs;
|
|
508
|
+
return current === "working";
|
|
509
|
+
};
|
|
510
|
+
for (;;) {
|
|
511
|
+
while (current === "working" || current === "idle") {
|
|
512
|
+
// A turn may finish before observation begins (or between waits).
|
|
513
|
+
if (current === "idle") {
|
|
514
|
+
const answer = await maybeAnswer();
|
|
515
|
+
if (answer !== undefined)
|
|
516
|
+
return answer;
|
|
517
|
+
}
|
|
518
|
+
await observeProgress();
|
|
519
|
+
const remaining = deadline - Date.now();
|
|
520
|
+
if (remaining <= 0)
|
|
521
|
+
throw new Error(`Herdr agent in tab ${tabId} made no progress for ${idleMs}ms; inspect ${sessionFile} before retrying.`);
|
|
522
|
+
const args = ["agent", "wait", name,
|
|
523
|
+
...(current === "idle" ? ["--until", "working"] : ["--until", "idle"]),
|
|
524
|
+
"--until", "done", "--until", "blocked", "--until", "unknown",
|
|
525
|
+
"--timeout", String(Math.max(1, Math.min(current === "idle" ? 1000 : OPERATION_MS - 1000, remaining)))];
|
|
526
|
+
const waited = await herdr.exec(args, options(signal));
|
|
527
|
+
if (waited.code !== 0 || waited.killed) {
|
|
528
|
+
if (!waited.killed && hasHerdrErrorCode(waited, "timeout")) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
throw new Error(herdrCommandFailure(args, waited));
|
|
532
|
+
}
|
|
533
|
+
const next = inspect(JSON.parse(waited.stdout), "agent_info", name, paneId, tabId);
|
|
534
|
+
if (next !== current)
|
|
535
|
+
deadline = Date.now() + idleMs;
|
|
536
|
+
current = next;
|
|
537
|
+
}
|
|
538
|
+
if (current !== "done")
|
|
539
|
+
throw new Error(`Herdr agent in tab ${tabId} became ${current}; inspect it before retrying.`);
|
|
540
|
+
const answer = await maybeAnswer();
|
|
541
|
+
if (answer !== undefined)
|
|
542
|
+
return answer;
|
|
543
|
+
if (!await retryPending())
|
|
544
|
+
return readAnswer();
|
|
510
545
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
546
|
+
},
|
|
547
|
+
async focus() {
|
|
548
|
+
// A reused pane ID must not receive focus: the agent identity must match the launch first.
|
|
549
|
+
inspect(await herdr.json(["agent", "get", paneId], options()), "agent_info", name, paneId, tabId);
|
|
550
|
+
await herdr.json(["agent", "focus", paneId], options());
|
|
514
551
|
},
|
|
515
552
|
async cancel() {
|
|
516
553
|
await herdr.json(["agent", "send-keys", name, "ctrl+c"], options());
|
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/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/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.
|
|
@@ -141,6 +143,8 @@ If the extension cannot prove termination of the stopped worker, the workflow ke
|
|
|
141
143
|
|
|
142
144
|
The extension retrieves each final answer from its exact Pi session file, not its possibly truncated screen. Idle, blocked, unknown, or malformed answers fail with recoverable tab and session identities. No whole-run deadline applies.
|
|
143
145
|
|
|
146
|
+
A provider error, such as HTTP 429, can end a worker turn before Pi retries it automatically. When the final turn has `stopReason: "error"`, the extension waits up to 65 seconds for the worker to work again, at most 5 times for one task. The task fails only when Pi does not retry in that time.
|
|
147
|
+
|
|
144
148
|
The tasks in a request share a 40 KiB inline answer budget. Each worker prompt states its share. A longer exact answer is still a success. The result shows a UTF-8-safe prefix and a truncation marker. The marker gives the path of a private `answer-<turn>.md` file beside the session file, which holds the full answer. For a write task, the budget applies to the `answer` field of the completion object. A chain step receives the full answer of the previous step in `{previous}`; the budget applies only to the result for Main. The result details keep `answerFile` for each full-answer file. Recovery text includes these paths outside the output limit, even when checkout notes remove the inline link.
|
|
145
149
|
|
|
146
150
|
Herdr accepts prompt text as one command argument. To stay below the operating system's argument limit, a task packet above 64 KiB uses a private, exclusively created `task-<turn>.md` file beside the session file. This packet includes the full chain context. The worker prompt requires the Role's `read` tool to read all parts with line offsets before work starts. Pi's native `read` can return a line of exactly 50 KiB (51,200 bytes), but not a longer line. If any packet line is longer, or `read` is absent, the worker uses the Role's `bash` tool. If a line is too long and `bash` is absent, the task fails before tab creation with the read limit and a request for `bash` or shorter lines. A Role with neither tool also fails before tab creation; the extension adds no tool. The task file stays available for recovery.
|
|
@@ -180,7 +184,19 @@ Each direct row starts with a saved direct reference such as `D7.1`. `D7` is the
|
|
|
180
184
|
|
|
181
185
|
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
186
|
|
|
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
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
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:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
Main · 2 working · 1 complete
|
|
193
|
+
├─ ⠋ D7 · 2 workers
|
|
194
|
+
│ ├─ ⠋ D7.1 [S] Map entry points · …
|
|
195
|
+
│ └─ ⠋ D7.2 [S] Map tests · …
|
|
196
|
+
└─ ✓ D8.1 [R] complete · Review patch · …
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
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
200
|
|
|
185
201
|
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
202
|
|
|
@@ -467,7 +483,7 @@ Every child launch:
|
|
|
467
483
|
2. loads only declared resources plus required internal adapters;
|
|
468
484
|
3. resolves Skill and model names from Pi's effective registries;
|
|
469
485
|
4. installs an exact tool policy;
|
|
470
|
-
5. excludes Main-only delegation, integration, and recovery tools, `herdr_handoff`, and `ask_question`; and
|
|
486
|
+
5. excludes Main-only delegation, integration, and recovery tools, `herdr_handoff`, `herdr_command`, and `ask_question`; and
|
|
471
487
|
6. prepends the delegated-subagent identity contract.
|
|
472
488
|
|
|
473
489
|
An empty `tools` list adds no base tools. Selected extension tools can still activate.
|
|
@@ -496,7 +512,7 @@ Before launch, the extension checks each selected entry against Pi's documented
|
|
|
496
512
|
|
|
497
513
|
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
514
|
|
|
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.
|
|
515
|
+
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
516
|
|
|
501
517
|
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
518
|
|
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,3 +1,4 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
1
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";
|
|
@@ -914,10 +915,20 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
914
915
|
const invalidIds: string[] = [];
|
|
915
916
|
const selectorOf = (root: string) => found.repos.find((item) => item.root === root)?.repo
|
|
916
917
|
?? (isWithin(found.base, root) ? relative(found.base, root).split(sep).join("/") : root);
|
|
918
|
+
const warnings = discoveryWarnings(found);
|
|
917
919
|
for (const { repo, root } of found.repos) {
|
|
918
920
|
if (!current()) throw new Error("Session or branch changed; reopen /subagent.");
|
|
919
|
-
|
|
920
|
-
|
|
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
|
+
}
|
|
921
932
|
for (const request of listed.requests) requests.push({ ...request, root: request.root ?? root, repo, leaseLive, checkout: root });
|
|
922
933
|
invalidIds.push(...listed.invalidIds.map((id) => `${repo}/${id}`));
|
|
923
934
|
}
|
|
@@ -929,7 +940,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
929
940
|
}
|
|
930
941
|
invalidIds.push(...orphans.invalidIds);
|
|
931
942
|
return { root: found.base, leaseLive: requests.some((request) => request.leaseLive), requests,
|
|
932
|
-
invalidIds: [...new Set(invalidIds)], warnings
|
|
943
|
+
invalidIds: [...new Set(invalidIds)], warnings };
|
|
933
944
|
};
|
|
934
945
|
return {
|
|
935
946
|
async inventory(cwd, current) {
|
|
@@ -967,7 +978,10 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
967
978
|
const found = await discoverRepos(cwd);
|
|
968
979
|
const lines: string[] = [];
|
|
969
980
|
for (const { repo, root: child } of found.repos) {
|
|
970
|
-
const listed = await getComponents().runner.listRequests(child)
|
|
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
|
+
});
|
|
971
985
|
if (!listed.requests.length && !listed.invalidIds.length) continue;
|
|
972
986
|
lines.push(`repo ${repo}:`, ...await recoveryLines(child));
|
|
973
987
|
}
|
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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hcflabs/pi-subagent",
|
|
3
|
-
"version": "28.
|
|
3
|
+
"version": "28.2.2",
|
|
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"
|