@henryqw/pi-subagent 26.4.0 → 26.5.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 CHANGED
@@ -6,7 +6,7 @@ Delegate work to configured Pi Roles with [Herdr](https://herdr.dev/). Authorize
6
6
 
7
7
  ## Install
8
8
 
9
- Requires Pi 1.1.0 or later in the 1.x series. Development and automated checks use Pi 1.1.0. Older Pi releases are no longer supported.
9
+ Requires Pi 1.1.0 or later in the 1.x series. Development and automated checks use Pi 1.1.0. Older Pi releases are no longer supported. Isolated work requires Herdr 0.9.3 or later, with a running Herdr server on API protocol 22 or later; Pi Subagent checks both before it starts.
10
10
 
11
11
  ```bash
12
12
  pi install npm:@henryqw/pi-task-models
@@ -26,7 +26,6 @@ export declare function directSessionUsage(jsonl: string, prompt: string): {
26
26
  tokens?: number;
27
27
  activity?: DirectToolActivity;
28
28
  };
29
- export declare function directSessionTokens(jsonl: string, prompt: string): number | undefined;
30
29
  export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile"> & {
31
30
  leasePath: string;
32
31
  };
@@ -160,9 +160,6 @@ export function directSessionUsage(jsonl, prompt) {
160
160
  return { tokens: observed ? total : undefined,
161
161
  activity: pending.size || latest ? { pending: [...pending.values()], latest } : undefined };
162
162
  }
163
- export function directSessionTokens(jsonl, prompt) {
164
- return directSessionUsage(jsonl, prompt).tokens;
165
- }
166
163
  export class DirectAllocationError extends Error {
167
164
  allocation;
168
165
  constructor(allocation, cause) {
package/dist/ephemeral.js CHANGED
@@ -110,13 +110,7 @@ function validateRunInput(value) {
110
110
  if (input.onActivity !== undefined && typeof input.onActivity !== "function") {
111
111
  throw new TypeError("run.onActivity must be a function.");
112
112
  }
113
- return {
114
- signal: input.signal,
115
- prepare: input.prepare,
116
- onUpdate: input.onUpdate,
117
- onTokens: input.onTokens,
118
- onActivity: input.onActivity,
119
- };
113
+ return value;
120
114
  }
121
115
  function record(value, field) {
122
116
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -217,8 +211,6 @@ export function createEphemeralSubagentExecutor(options) {
217
211
  throw abortError(input.signal, cause);
218
212
  throw new EphemeralSubagentError("prepare", cause instanceof Error ? cause.message : String(cause), cause);
219
213
  }
220
- if (input.signal?.aborted)
221
- throw abortError(input.signal);
222
214
  return await runPi(prepared, input, validated, invocation);
223
215
  }
224
216
  finally {
@@ -126,7 +126,6 @@ export declare class HerdrHostRuntime implements HostRuntime {
126
126
  private workerTurnFinished;
127
127
  private removeWorkerSessionFile;
128
128
  private assertLeasePath;
129
- private assertPrivateLease;
130
129
  private scanLease;
131
130
  private signalExactHolders;
132
131
  private listTabs;
@@ -11,7 +11,7 @@ import { runProcess as defaultRunProcess } from "./process.js";
11
11
  import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
12
12
  import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
13
13
  import { exactDirectTerminalTurn } from "./direct-herdr.js";
14
- const MIN_HERDR_VERSION = [0, 9, 0];
14
+ const MIN_HERDR_VERSION = [0, 9, 3];
15
15
  const MIN_HERDR_PROTOCOL = 22;
16
16
  const HERDR_OPERATION_CAP_MS = 30_000;
17
17
  const LSOF_OPERATION_CAP_MS = 3_000;
@@ -276,7 +276,7 @@ export class HerdrHostRuntime {
276
276
  const versionOutput = await this.herdr.run(["--version"], this.processOptions(root, context, HERDR_OPERATION_CAP_MS));
277
277
  const versionMatch = /^herdr (\S+)\s*$/.exec(versionOutput);
278
278
  if (!versionMatch || !compareVersion(versionMatch[1], MIN_HERDR_VERSION)) {
279
- throw new Error("Herdr client version must be at least 0.9.0.");
279
+ throw new Error("Herdr client version must be at least 0.9.3.");
280
280
  }
281
281
  const status = statusFields(await this.herdr.run(["status", "server"], this.processOptions(root, context, HERDR_OPERATION_CAP_MS)));
282
282
  if (status.get("status") !== "running"
@@ -285,7 +285,7 @@ export class HerdrHostRuntime {
285
285
  || status.get("private_protocol_compatible") !== "yes"
286
286
  || !/^\d+$/.test(status.get("private_protocol") ?? "")
287
287
  || Number(status.get("private_protocol")) < MIN_HERDR_PROTOCOL) {
288
- throw new Error("Herdr server must be compatible version >=0.9.0 with private protocol >=22.");
288
+ throw new Error("Herdr server must be compatible version >=0.9.3 with private protocol >=22.");
289
289
  }
290
290
  const schema = await this.herdr.json(["api", "schema", "--json"], this.processOptions(root, context, HERDR_OPERATION_CAP_MS));
291
291
  requireSchemaCapabilities(schema);
@@ -438,7 +438,7 @@ export class HerdrHostRuntime {
438
438
  assertAgentIntent(allocation, input.attempt);
439
439
  this.assertLeasePath(allocation.leasePath, input.intent.token);
440
440
  try {
441
- await this.assertPrivateLease(allocation.leasePath, false);
441
+ await assertPrivateLease(allocation.leasePath, false);
442
442
  const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => {
443
443
  const name = agent.name;
444
444
  if (name !== undefined && name !== null && typeof name !== "string")
@@ -547,7 +547,7 @@ export class HerdrHostRuntime {
547
547
  if (input.workerId !== allocation.agentName)
548
548
  throw new Error("Worker termination identity does not match the exact saved agent.");
549
549
  this.assertLeasePath(allocation.leasePath, allocation.token);
550
- await this.assertPrivateLease(allocation.leasePath, false);
550
+ await assertPrivateLease(allocation.leasePath, false);
551
551
  const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
552
552
  name: typeof agent.name === "string" ? agent.name : undefined,
553
553
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
@@ -604,7 +604,7 @@ export class HerdrHostRuntime {
604
604
  if (input.workerId !== allocation.agentName)
605
605
  throw new Error("Saved termination worker does not match the exact owned agent.");
606
606
  this.assertLeasePath(allocation.leasePath, allocation.token);
607
- await this.assertPrivateLease(allocation.leasePath, false);
607
+ await assertPrivateLease(allocation.leasePath, false);
608
608
  const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
609
609
  name: typeof agent.name === "string" ? agent.name : undefined,
610
610
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
@@ -649,7 +649,7 @@ export class HerdrHostRuntime {
649
649
  assertAgentIntent(allocation, attempt);
650
650
  this.assertLeasePath(allocation.leasePath, attempt.correlationToken);
651
651
  await this.assertPrivateLeaseDirectories(allocation.leasePath, true);
652
- await this.assertPrivateLease(allocation.leasePath, true);
652
+ await assertPrivateLease(allocation.leasePath, true);
653
653
  const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
654
654
  name: typeof agent.name === "string" ? agent.name : undefined,
655
655
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
@@ -826,7 +826,7 @@ export class HerdrHostRuntime {
826
826
  if (!acquireLaunch)
827
827
  throw new Error("Agent start requires immediate Role launch acquisition.");
828
828
  this.assertLeasePath(allocation.leasePath, allocation.token);
829
- await this.assertPrivateLease(allocation.leasePath, false);
829
+ await assertPrivateLease(allocation.leasePath, false);
830
830
  if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
831
831
  throw new Error("Agent start requires an empty exact process lease.");
832
832
  }
@@ -1057,12 +1057,12 @@ export class HerdrHostRuntime {
1057
1057
  finally {
1058
1058
  await file?.close();
1059
1059
  }
1060
- await this.assertPrivateLease(path, false);
1060
+ await assertPrivateLease(path, false);
1061
1061
  }
1062
1062
  async removeLeaseArtifacts(allocation, token, paneId, tabId, context) {
1063
1063
  this.assertLeasePath(allocation.leasePath, token);
1064
1064
  await this.assertPrivateLeaseDirectories(allocation.leasePath, true);
1065
- const leaseWasPresent = await this.assertPrivateLease(allocation.leasePath, true);
1065
+ const leaseWasPresent = await assertPrivateLease(allocation.leasePath, true);
1066
1066
  if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context, undefined, true)).length) {
1067
1067
  throw new Error("Exact process lease still has a holder during cleanup.");
1068
1068
  }
@@ -1080,10 +1080,10 @@ export class HerdrHostRuntime {
1080
1080
  if (leaseWasPresent) {
1081
1081
  if (!directoryPresent)
1082
1082
  throw new Error("Exact process lease directory disappeared during cleanup.");
1083
- await this.assertPrivateLease(allocation.leasePath, false);
1083
+ await assertPrivateLease(allocation.leasePath, false);
1084
1084
  await unlink(allocation.leasePath);
1085
1085
  }
1086
- else if (await this.assertPrivateLease(allocation.leasePath, true)) {
1086
+ else if (await assertPrivateLease(allocation.leasePath, true)) {
1087
1087
  throw new Error("Exact process lease appeared during cleanup.");
1088
1088
  }
1089
1089
  if (!directoryPresent)
@@ -1193,9 +1193,6 @@ export class HerdrHostRuntime {
1193
1193
  throw new Error("Process lease path is not the exact random path owned by this task token.");
1194
1194
  }
1195
1195
  }
1196
- async assertPrivateLease(path, allowMissing) {
1197
- return assertPrivateLease(path, allowMissing);
1198
- }
1199
1196
  async scanLease(path, cwd, context, pid, allowMissing = false) {
1200
1197
  return scanProcessLease(path, (args) => this.execute(this.lsofCommand, args, this.processOptions(cwd, context, LSOF_OPERATION_CAP_MS)), pid, allowMissing);
1201
1198
  }
@@ -14,9 +14,6 @@ export const ISOLATED_MODEL_TASK = {
14
14
  purpose: "Resolve one task-scoped isolated Role launch.",
15
15
  defaultProfile: "balanced",
16
16
  };
17
- function abortIfNeeded(signal) {
18
- signal?.throwIfAborted();
19
- }
20
17
  function isMissing(error) {
21
18
  return Boolean(error && typeof error === "object" && error.code === "ENOENT");
22
19
  }
@@ -46,7 +43,7 @@ async function removeTransientLaunch(directory, promptPath, promptRequired) {
46
43
  throw new AggregateError(failures, "Transient Role launch cleanup was incomplete.");
47
44
  }
48
45
  export async function materializeTransientLaunch(prepared, signal) {
49
- abortIfNeeded(signal);
46
+ signal?.throwIfAborted();
50
47
  const promptBytes = Buffer.from(prepared.prompt, "utf8");
51
48
  const created = await mkdtemp(join(tmpdir(), "pi-subagent-role-"));
52
49
  let directory = normalize(created);
@@ -66,7 +63,7 @@ export async function materializeTransientLaunch(prepared, signal) {
66
63
  file = await open(promptPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, PROMPT_MODE);
67
64
  promptCreated = true;
68
65
  await file.chmod(PROMPT_MODE);
69
- abortIfNeeded(signal);
66
+ signal?.throwIfAborted();
70
67
  await file.writeFile(promptBytes, { signal });
71
68
  await file.sync();
72
69
  await file.close();
@@ -124,7 +121,7 @@ export class RoleLaunchRuntime {
124
121
  return this.options.randomToken?.() ?? randomBytes(12).toString("hex");
125
122
  }
126
123
  async prepareLaunch(role, modelClass, context) {
127
- abortIfNeeded(context.signal);
124
+ context.signal?.throwIfAborted();
128
125
  const prepared = await resolveConfiguredRoleLaunch(this.options.pi, this.options.context(), { role, modelClass });
129
126
  return Object.freeze({
130
127
  launch: Object.freeze({
@@ -141,7 +138,7 @@ export class RoleLaunchRuntime {
141
138
  });
142
139
  }
143
140
  async preflight(input, context) {
144
- abortIfNeeded(context.signal);
141
+ context.signal?.throwIfAborted();
145
142
  const resolvedRoot = await this.options.resolveRoot(input.cwd, context);
146
143
  if (typeof resolvedRoot !== "string" || !isAbsolute(resolvedRoot) || resolvedRoot.includes("\0")) {
147
144
  throw new Error("Pi Subagent root resolver must return an absolute canonical path.");
@@ -152,7 +149,7 @@ export class RoleLaunchRuntime {
152
149
  const rootInfo = await lstat(root);
153
150
  if (!rootInfo.isDirectory())
154
151
  throw new Error("Pi Subagent root must be an existing local directory.");
155
- abortIfNeeded(context.signal);
152
+ context.signal?.throwIfAborted();
156
153
  if (input.request.tasks.some((task) => task.kind === "changeset")) {
157
154
  await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root }, context);
158
155
  }
package/dist/schema.js CHANGED
@@ -492,22 +492,19 @@ function normalizeText(value, field) {
492
492
  throw new Error(`${field} must be non-empty text without NUL bytes.`);
493
493
  return normalized;
494
494
  }
495
- function normalizeRole(value, field) {
496
- return parseRoleName(value, field);
497
- }
498
495
  function normalizeJudgment(judgment, field) {
499
496
  if (!judgment)
500
497
  return;
501
498
  return {
502
499
  ...judgment,
503
- role: normalizeRole(judgment.role, `${field}.role`),
500
+ role: parseRoleName(judgment.role, `${field}.role`),
504
501
  criterion: normalizeText(judgment.criterion, `${field}.criterion`),
505
502
  };
506
503
  }
507
504
  function normalizeTask(task, index) {
508
505
  const fields = {
509
506
  id: task.id,
510
- role: normalizeRole(task.role, `tasks[${index}].role`),
507
+ role: parseRoleName(task.role, `tasks[${index}].role`),
511
508
  modelClass: task.modelClass,
512
509
  requirements: normalizeText(task.requirements, `tasks[${index}].requirements`),
513
510
  deliverable: normalizeText(task.deliverable, `tasks[${index}].deliverable`),
@@ -12,10 +12,6 @@ export function roleCanWrite(role: Role): boolean {
12
12
  return role.tools.some((tool) => !READ_ONLY_TOOLS.has(tool)) || role.extensions.length > 0 || Boolean(role.mcps?.length);
13
13
  }
14
14
 
15
- export function roleIsReadOnlyScout(role: Role): boolean {
16
- return !roleCanWrite(role) && role.extensions.length === 0 && !role.mcps?.length;
17
- }
18
-
19
15
  async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
20
16
  const cwd = await realpath(ctx.cwd);
21
17
  const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5_000 });
@@ -2,7 +2,6 @@ import type { JsonValue } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
4
  import type { EphemeralSubagentExecutor } from "../dist/ephemeral.js";
5
- import { ROLE_TOOL_POLICY_FLAG } from "../dist/index.js";
6
5
  import type { EffectiveExecutionPolicy } from "./config.ts";
7
6
  import {
8
7
  createCanonicalGitRootResolver,
@@ -430,10 +429,6 @@ export interface RegisterIsolatedOptions {
430
429
 
431
430
  /** Register lifecycle surfaces; delegate_task remains the only start tool. */
432
431
  export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIsolatedOptions): IsolatedSurface {
433
- if (process.argv.includes(`--${ROLE_TOOL_POLICY_FLAG}`)) {
434
- const denied = (): never => { throw new Error("Child Roles cannot access isolated delegation."); };
435
- return { execute: async () => denied(), inventory: async () => denied(), recover: async () => denied(), inspect: async () => denied(), canFollowup: denied, enqueue: denied, drain: denied };
436
- }
437
432
  const componentsFactory = options.componentsFactory ?? createIsolatedComponents;
438
433
  let latestCtx: ExtensionContext | undefined;
439
434
  let components: IsolatedExtensionComponents | undefined;
@@ -4,7 +4,7 @@ import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@
4
4
  import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
5
5
  import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, resolveRolePackageResources, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
6
6
  import { readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
7
- import { registerCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
7
+ import { registerCheckoutAdmission, roleCanWrite } from "./admission.ts";
8
8
  import { registerIsolatedExtension } from "./isolated.ts";
9
9
  import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask } from "./subagent-command.ts";
10
10
  import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
@@ -278,7 +278,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
278
278
  const snapshot = notices.join("\n");
279
279
  if (Buffer.byteLength(snapshot, "utf8") > 32_000) throw new Error("Status exceeds the 32 KiB inspection limit; use subagent_status for exact evidence.");
280
280
  const role = loadRoles().find((candidate) => candidate.name === "scout");
281
- if (!role || !roleIsReadOnlyScout(role)) throw new Error("Status inspection requires a configured read-only scout Role without extensions or MCP servers.");
281
+ if (!role || roleCanWrite(role)) throw new Error("Status inspection requires a configured read-only scout Role without extensions or MCP servers.");
282
282
  const prepared = prepareRoleLaunch(pi, ctx, { role, task: DELEGATE_TASK, modelClass: "fast" });
283
283
  const herdr = createDirectHerdr(pi, ctx.cwd, currentPolicy().childIdleMs);
284
284
  const controller = new AbortController();
@@ -712,12 +712,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
712
712
  throw error;
713
713
  } finally { release(); }
714
714
  }, controller.signal);
715
- } catch (error) {
716
- if (!controller.signal.aborted) {
717
- const state = [...states.values()].find(({ status }) => status === "running" || status === "pending");
718
- if (state) states.set(state.id, { id: state.id, index: state.index, name: state.name, role: state.role, status: "rejected", failure: capOutput(String(error)) });
719
- }
720
- } finally {
715
+ } catch { /* aborted */ } finally {
721
716
  for (const [id, state] of states) {
722
717
  if (state.status === "pending" || state.status === "running") states.set(id, {
723
718
  id: state.id, index: state.index, name: state.name, role: state.role,
@@ -733,7 +728,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
733
728
  }
734
729
  })();
735
730
  return {
736
- content: [{ type: "text" as const, text: capOutput(`Direct delegation started · ${ref} · ${taskId}\nHerdr tab: ${first.tabId} · pane: ${first.paneId} · agent: ${first.name}\nExact session: ${first.sessionFile}\nAll launched tabs: /subagent (on this session branch).\n${entries.length} task(s); result will arrive in one follow-up message. Direct work shares this checkout; completion is an exact answer, not checked integration. ${writes ? "Potential writers run serially; competing Pi writes stay blocked until owned workers are proved stopped. If termination is uncertain, use /subagent ${ref} Close/cancel-and-release (stops ALL its owned workers); changes are not rolled back." : "Concurrent changes may make reads stale."}`) }],
731
+ content: [{ type: "text" as const, text: capOutput(`Direct delegation started · ${ref} · ${taskId}\nHerdr tab: ${first.tabId} · pane: ${first.paneId} · agent: ${first.name}\nExact session: ${first.sessionFile}\nAll launched tabs: /subagent (on this session branch).\n${entries.length} task(s); result will arrive in one follow-up message. Direct work shares this checkout; completion is an exact answer, not checked integration. ${writes ? `Potential writers run serially; competing Pi writes stay blocked until owned workers are proved stopped. If termination is uncertain, use /subagent ${ref} Close/cancel-and-release (stops ALL its owned workers); changes are not rolled back.` : "Concurrent changes may make reads stale."}`) }],
737
732
  details: { taskId, mode: workflow.mode, tabId: first.tabId, sessionFile: first.sessionFile,
738
733
  entries: entries.map(({ id, index, delegation }) => ({ id, index, name: delegation.name, role: delegation.role })) },
739
734
  };
@@ -148,12 +148,7 @@ export async function runForegroundWorkflow(
148
148
  signal?: AbortSignal,
149
149
  ): Promise<void> {
150
150
  signal?.throwIfAborted();
151
- if (mode === "single") {
152
- await runEntry(entries[0]!, run);
153
- signal?.throwIfAborted();
154
- return;
155
- }
156
- if (mode === "parallel") {
151
+ if (mode === "single" || mode === "parallel") {
157
152
  await Promise.all(entries.map((entry) => runEntry(entry, run)));
158
153
  signal?.throwIfAborted();
159
154
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "26.4.0",
3
+ "version": "26.5.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@henryqw/pi-config-store": "^3.0.0",
70
- "@henryqw/pi-herdr": "^0.4.9",
70
+ "@henryqw/pi-herdr": "^0.5.0",
71
71
  "@henryqw/pi-multi-codex": "^3.0.0",
72
72
  "@henryqw/pi-process": "^0.2.0",
73
73
  "@henryqw/pi-task-models": "^8.0.0",