@henryqw/pi-subagent 27.5.3 → 27.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Commands and UI are for you. Tools and the packaged skill are for Main, except `
33
33
  | `/subagent roles` | command | List Role sources, resources, resolved model and thinking level, route errors, and file errors. Read-only; requires no Herdr pane. |
34
34
  | `/subagent prune` | command | Review remaining worker and integration branches and worktrees. Remove merged resources after confirmation. Requires a running Herdr server; without interactive UI, only reports. |
35
35
  | `/subagent recover` | command | Classify interrupted isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
36
- | `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. |
36
+ | `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. With `dryRun: true`, check the request and show the resolved plan without a launch. |
37
37
  | `subagent_status` | tool | Read saved isolated state, exact identities, failed review verdicts, and allowed continuation. Does not replay work. Supports structured output for `codemode`. |
38
38
  | `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation, not staging or integration. Same-worker correction retries accept optional `instruction` text. |
39
39
  | `subagent_stage` | tool | Stage, resolve, reject, or revise an exact candidate. Rejecting a staged candidate freezes its generation. |
@@ -41,7 +41,7 @@ Commands and UI are for you. Tools and the packaged skill are for Main, except `
41
41
  | `subagent_abort` | tool | Terminate an isolated request without discarding retained work. Retained candidates and integration work require release or proof of absence. |
42
42
  | `git_read` | tool | Let Role-declared children inspect bounded local Git evidence without Bash, tests, network access, or writes. Not available to Main. |
43
43
  | `pi-subagent` | skill | Guide Main through authorization, delegation, checks, integration, and recovery. |
44
- | Direct status widget (`D`) | ui | Show direct references, names, Role badges, status, routes, elapsed time, measured task tokens, and tool activity in the TUI. |
44
+ | Direct status widget (`D`) | ui | Show direct references, names, Role badges, status, routes, elapsed time, measured task tokens, cost, and tool activity in the TUI. |
45
45
  | Isolated status widget (`I`) | ui | Show recorded task state, Role/model-class badges, and retained workspaces in the TUI. Readiness does not mean promotion. |
46
46
 
47
47
  `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`.
@@ -82,7 +82,11 @@ Tasks in write requests must return one JSON completion object:
82
82
 
83
83
  `outcome` accepts `succeeded`, `failed`, or `blocked`; `answer` must be non-empty. Plain text, malformed completion, blocked work, or failure stops later dispatch. Success is the worker's report, not independent validation. Checkout admission depends on proved termination, not the reported outcome.
84
84
 
85
- The launch returns a task ID and first Herdr tab, not the answer. One follow-up delivers results when the workflow finishes. Busy Main receives it after its current turn; idle Main starts a turn. Failed partial output stays marked as partial, never success. Worker output carries no user authority.
85
+ A write task can declare `"paths": ["src/**"]` with repo-relative globs. For each task in a write request, the result lists new commits and changed paths, and it flags changed paths outside `paths`. This is a report only, not enforcement, and it cannot exclude changes from other processes. For a read request, the result warns `checkout changed during task; result may be stale` when the checkout changed while the task ran. Outside a Git repository, the extension makes no report. See [Checkout change reports](./docs/orchestration.md#checkout-change-reports).
86
+
87
+ To check a request before a launch, add `"dryRun": true`. The dry run does the launch checks and shows each task's Role, model, thinking level, access, and dependencies. It starts no worker and opens no tab. It also reserves no checkout and sends no follow-up. A failed check gives the same error as a launch. For the full list of checks, see [Dry run](./docs/orchestration.md#dry-run).
88
+
89
+ The launch returns a task ID and first Herdr tab, not the answer. One follow-up delivers results when the workflow finishes. The results show the tokens and cost of each worker and the total cost of the request. Busy Main receives it after its current turn; idle Main starts a turn. Failed partial output stays marked as partial, never success. Worker output carries no user authority.
86
90
 
87
91
  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 eligibility and stop behavior, see [Continue or stop one direct worker](./docs/orchestration.md#continue-or-stop-one-direct-worker).
88
92
 
@@ -125,6 +129,10 @@ Keep the durable ID; Main can work or end its turn without polling. Inspect read
125
129
 
126
130
  Readiness requires preliminary checks and any task `judgment`; it does not permit promotion. Checks and judgments bind to exact Git identities. Staging, combined validation, and promotion require unchanged, clean Main at the pinned identity. Changed identities, conflicts, or uncertain outcomes block promotion and preserve evidence. A definitive combined check or review failure permits one committed integration correction, recorded with `subagent_integrate correct`, and another validation.
127
131
 
132
+ Combined validation can reuse a passed preliminary check of a staged candidate. The candidate must have the same tree as the combined tip, and the check must have the identical command and argv. Then the final check does not run again. This usually occurs when you stage one candidate onto unchanged Main. Status shows each reused result and its candidate. Final judgment still runs.
133
+
134
+ Reuse assumes that checks use only tracked content. Ignored files, for example `node_modules`, can be different between worktrees.
135
+
128
136
  The extension sends follow-ups when a candidate becomes ready, a worker needs attention, and the wave finishes. A missed follow-up does not authorize replay. Read [Isolated contract](./docs/orchestration.md#isolated-contract) and [Exact integration actions](./docs/orchestration.md#exact-integration-actions) for graph, judgment, revision, refresh, and release details.
129
137
 
130
138
  ### Inspect work
@@ -8,10 +8,10 @@ export interface DirectHandle {
8
8
  sessionFile: string;
9
9
  prompt: string;
10
10
  answer(maxBytes: number): Promise<string>;
11
- usageTokens(): Promise<number | undefined>;
11
+ usage(): Promise<DirectUsage>;
12
12
  /** Bounded assistant text from this turn so far; failure evidence only, never completion. */
13
13
  partialText(maxChars: number): Promise<string | undefined>;
14
- /** Display-only activity from the last usageTokens() sample. */
14
+ /** Display-only activity from the last usage() sample. */
15
15
  readonly activity?: DirectToolActivity;
16
16
  cancel(): Promise<void>;
17
17
  }
@@ -19,6 +19,12 @@ export declare function exactDirectTerminalTurn(jsonl: string, prompt: string):
19
19
  /** Refuse continuation unless the saved session's exact recorded turn is terminal and no later user turn exists. */
20
20
  export declare function assertSettledDirectSession(sessionFile: string, turn: string): Promise<void>;
21
21
  export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number, requireSuccess?: boolean): string;
22
+ /** Measured from one session read; each field is absent when no turn record carried it. */
23
+ export interface DirectUsage {
24
+ tokens?: number;
25
+ /** Sum of assistant `usage.cost.total`. */
26
+ cost?: number;
27
+ }
22
28
  export interface DirectToolActivity {
23
29
  pending: string[];
24
30
  latest?: {
@@ -26,14 +32,13 @@ export interface DirectToolActivity {
26
32
  durationMs?: number;
27
33
  };
28
34
  }
29
- export declare function directSessionUsage(jsonl: string, prompt: string): {
30
- tokens?: number;
35
+ export declare function directSessionUsage(jsonl: string, prompt: string): DirectUsage & {
31
36
  activity?: DirectToolActivity;
32
37
  };
33
38
  /** The last `maxChars` of assistant text in the exact prompt's turn; ignores a partial trailing entry. */
34
39
  export declare function directPartialText(jsonl: string, prompt: string, maxChars: number): string | undefined;
35
40
  /** One reader per handle; only complete, valid lines advance the byte offset. */
36
- export declare function createDirectSessionReader(sessionFile: string, prompt: string): Pick<DirectHandle, "usageTokens" | "partialText" | "activity">;
41
+ export declare function createDirectSessionReader(sessionFile: string, prompt: string): Pick<DirectHandle, "usage" | "partialText" | "activity">;
37
42
  /** `turn` is the prompt's random turn identity; it proves which session turn a record owns.
38
43
  * `workspaceId` and `cwd` bind the record to its launching Herdr workspace and checkout. */
39
44
  export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile"> & {
@@ -126,6 +126,7 @@ class DirectSessionState {
126
126
  active = false;
127
127
  total = 0;
128
128
  observed = false;
129
+ cost;
129
130
  pending = new Map();
130
131
  latest;
131
132
  texts = [];
@@ -150,6 +151,7 @@ class DirectSessionState {
150
151
  if (this.active) {
151
152
  this.total = 0;
152
153
  this.observed = false;
154
+ this.cost = undefined;
153
155
  this.pending.clear();
154
156
  this.latest = undefined;
155
157
  this.texts = [];
@@ -163,6 +165,9 @@ class DirectSessionState {
163
165
  this.total += values.reduce((sum, value) => sum + value, 0);
164
166
  this.observed = true;
165
167
  }
168
+ const cost = usage.cost?.total;
169
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0)
170
+ this.cost = (this.cost ?? 0) + cost;
166
171
  }
167
172
  if (Array.isArray(message.content))
168
173
  for (const part of message.content) {
@@ -187,7 +192,7 @@ class DirectSessionState {
187
192
  return true;
188
193
  }
189
194
  usage() {
190
- return { tokens: this.observed ? this.total : undefined,
195
+ return { tokens: this.observed ? this.total : undefined, cost: this.cost,
191
196
  activity: this.pending.size || this.latest ? { pending: [...this.pending.values()], latest: this.latest } : undefined };
192
197
  }
193
198
  partialText(maxChars) {
@@ -258,9 +263,9 @@ export function createDirectSessionReader(sessionFile, prompt) {
258
263
  const sample = () => reading ??= read().finally(() => { reading = undefined; });
259
264
  return {
260
265
  get activity() { return lastUsage.activity; },
261
- async usageTokens() {
266
+ async usage() {
262
267
  lastUsage = await sample() ? state.usage() : {};
263
- return lastUsage.tokens;
268
+ return { tokens: lastUsage.tokens, cost: lastUsage.cost };
264
269
  },
265
270
  async partialText(maxChars) {
266
271
  return await sample() ? state.partialText(maxChars) : undefined;
@@ -426,7 +431,7 @@ export function createDirectHerdr(pi, cwd, idleMs) {
426
431
  return {
427
432
  name, tabId, paneId, sessionFile, prompt,
428
433
  get activity() { return session.activity; },
429
- usageTokens: session.usageTokens,
434
+ usage: session.usage,
430
435
  partialText: session.partialText,
431
436
  async answer(maxBytes) {
432
437
  const readAnswer = async () => {
@@ -1,5 +1,5 @@
1
1
  import { type WorktreeInfo } from "./worktree.ts";
2
- import { type CheckBatchEvidence, type CheckCommand, type ReviewEvidence, type WorkspaceIdentity } from "./schema.ts";
2
+ import { type CheckBatchEvidence, type CheckCommand, type IntegrationCandidate, type ReviewEvidence, type WorkspaceIdentity } from "./schema.ts";
3
3
  export type StageReceipt = {
4
4
  previous: WorkspaceIdentity;
5
5
  worker: WorkspaceIdentity;
@@ -42,6 +42,8 @@ export declare class IntegrationGit {
42
42
  stages: readonly StageReceipt[];
43
43
  checks: CheckBatchEvidence;
44
44
  commands: readonly CheckCommand[];
45
+ /** Staged candidates that may supply reused final results; omitted accepts no reuse. */
46
+ sources?: readonly IntegrationCandidate[];
45
47
  review?: ReviewEvidence;
46
48
  criterion?: string;
47
49
  correction?: {
@@ -317,7 +317,8 @@ export class IntegrationGit {
317
317
  return { outcome: "blocked", failure: "No worker stages to promote." };
318
318
  const tip = input.correction ? (await this.inspectCorrection(root, integration, base, stages, input.correction.from, input.correction.to, signal), input.correction.to)
319
319
  : await this.inspectCombined(root, integration, base, stages, signal);
320
- if (input.checks.phase !== "final" || !checkBatchPasses(input.checks, input.commands, tip)
320
+ const sources = (input.sources ?? []).filter((source) => stages.some((stage) => sameIdentity(stage.worker, source.tip)));
321
+ if (input.checks.phase !== "final" || !checkBatchPasses(input.checks, input.commands, tip, sources)
321
322
  || (input.criterion !== undefined && !reviewEvidencePasses(input.review, "final", input.criterion, base, tip))
322
323
  || (input.criterion === undefined && input.review !== undefined)) {
323
324
  return { outcome: "blocked", failure: "Exact combined-tip final checks/review evidence is missing or failed." };
@@ -1,5 +1,5 @@
1
1
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { CoordinatorRuntime, OperationContext, TransientLaunchHandle, VerifiedLaunch } from "./runner.ts";
2
+ import type { CoordinatorRuntime, OperationContext, ResolvedRoute, TransientLaunchHandle, VerifiedLaunch } from "./runner.ts";
3
3
  import { type ExecuteRequest, type ModelClass, type WorkspaceIdentity } from "./schema.ts";
4
4
  export declare const ISOLATED_MODEL_TASK: {
5
5
  readonly id: "pi-subagent/isolatedRoleLaunch";
@@ -43,9 +43,11 @@ export declare class RoleLaunchRuntime implements CoordinatorRuntime {
43
43
  preflight(input: {
44
44
  request: ExecuteRequest;
45
45
  cwd: string;
46
+ dryRun?: boolean;
46
47
  }, context: OperationContext): Promise<{
47
48
  root: string;
48
49
  main: WorkspaceIdentity;
50
+ routes: ResolvedRoute[];
49
51
  }>;
50
52
  acquireLaunch(role: string, modelClass: ModelClass, context: OperationContext): Promise<TransientLaunchHandle<VerifiedLaunch>>;
51
53
  }
@@ -150,7 +150,7 @@ export class RoleLaunchRuntime {
150
150
  if (!rootInfo.isDirectory())
151
151
  throw new Error("Pi Subagent root must be an existing local directory.");
152
152
  context.signal?.throwIfAborted();
153
- if (input.request.tasks.some((task) => task.kind === "changeset")) {
153
+ if (!input.dryRun && input.request.tasks.some((task) => task.kind === "changeset")) {
154
154
  await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root }, context);
155
155
  }
156
156
  const main = await this.options.inspectMainBase({ root }, context);
@@ -168,11 +168,14 @@ export class RoleLaunchRuntime {
168
168
  if (input.request.finalJudgment) {
169
169
  addRequired(input.request.finalJudgment.role, input.request.finalJudgment.modelClass);
170
170
  }
171
+ const routes = [];
171
172
  for (const [role, modelClasses] of required) {
172
- for (const modelClass of modelClasses)
173
- await this.prepareLaunch(role, modelClass, context);
173
+ for (const modelClass of modelClasses) {
174
+ const { launch } = await this.prepareLaunch(role, modelClass, context);
175
+ routes.push({ role, modelClass, model: launch.model, thinkingLevel: launch.thinkingLevel });
176
+ }
174
177
  }
175
- return { root, main };
178
+ return { root, main, routes };
176
179
  }
177
180
  async acquireLaunch(role, modelClass, context) {
178
181
  const prepared = await this.prepareLaunch(role, modelClass, context);
package/dist/runner.d.ts CHANGED
@@ -96,6 +96,14 @@ export interface VerifiedLaunch {
96
96
  readonly env: Readonly<Record<string, string>>;
97
97
  readonly tools: readonly string[];
98
98
  }
99
+ export type ResolvedRoute = Pick<VerifiedLaunch, "role" | "modelClass" | "model" | "thinkingLevel">;
100
+ /** A dry-run result: the normalized request and the resolved routes; no state exists for it. */
101
+ export interface ExecutePlan {
102
+ request: ExecuteRequest;
103
+ root: string;
104
+ main: WorkspaceIdentity;
105
+ routes: readonly ResolvedRoute[];
106
+ }
99
107
  export interface TransientLaunchHandle<Launch extends VerifiedLaunch = VerifiedLaunch> {
100
108
  readonly launch: Launch;
101
109
  cleanup(): Promise<void>;
@@ -109,12 +117,16 @@ export declare function withTransientLaunch<Launch extends VerifiedLaunch, Resul
109
117
  export interface CoordinatorRuntime {
110
118
  now(): number;
111
119
  randomToken(): string;
120
+ /** `dryRun` skips checks that call Herdr; every other check runs. */
112
121
  preflight(input: {
113
122
  request: ExecuteRequest;
114
123
  cwd: string;
124
+ dryRun?: boolean;
115
125
  }, context: OperationContext): Promise<{
116
126
  root: string;
117
127
  main: WorkspaceIdentity;
128
+ /** Resolved route of each distinct Role and model class, when the coordinator reports it. */
129
+ routes?: readonly ResolvedRoute[];
118
130
  }>;
119
131
  acquireLaunch(role: string, modelClass: ModelClass, context: OperationContext): Promise<TransientLaunchHandle<VerifiedLaunch>>;
120
132
  }
@@ -340,6 +352,10 @@ export declare class IsolatedRunner {
340
352
  private saveProductive;
341
353
  private callProductive;
342
354
  private rethrowStopped;
355
+ /** Every execute check before durable state; a dry run also skips the Herdr host preflight. */
356
+ private admitExecute;
357
+ /** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
358
+ plan(value: unknown, cwd: string, outerSignal?: AbortSignal): Promise<ExecutePlan>;
343
359
  execute(value: unknown, cwd: string, outerSignal?: AbortSignal): Promise<RunResponse>;
344
360
  resume(value: unknown, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
345
361
  /** Main selects an exact retained candidate; only the owned integration checkout is writable. */
@@ -350,6 +366,7 @@ export declare class IsolatedRunner {
350
366
  private releaseRejected;
351
367
  private releaseSuperseded;
352
368
  private cleanupPromoted;
369
+ private cleanupPromotedStage;
353
370
  private requireExactIntegration;
354
371
  listOrphanRequests(cwd: string): Promise<{
355
372
  invalidIds: string[];