@ferris1225/pi-subagents 4.1.18 → 4.1.21

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/src/widget.ts CHANGED
@@ -1,9 +1,25 @@
1
- /** Lightweight active-run widget for interactive Pi sessions. */
1
+ /**
2
+ * Compact, glanceable active-run widget for interactive Pi sessions.
3
+ *
4
+ * Layout contract (redesign):
5
+ * - Aligned columns: `icon #id agent` pad to the widest displayed id and
6
+ * agent so every label starts at the same column; a resumed thread carries
7
+ * a dim `↻` inside the agent column.
8
+ * - Visual hierarchy: the label (what the run owns) is plain, the live
9
+ * activity after ` — ` is dim, and all telemetry — worktree badge,
10
+ * model/thinking, wait state, elapsed — is one dim right-aligned column,
11
+ * so times and states line up at the right edge.
12
+ * - Two lines per managed workflow: the stable parent line plus a `└`-connected
13
+ * stage timeline; the live stage's activity/model/elapsed rides right-aligned
14
+ * on the timeline line. Internal child rows are not repeated as extra lines.
15
+ * - Queued rows say what they actually wait for ("queued" for a process slot,
16
+ * "repo lane" for shared-writer serialization, "starting" while the child
17
+ * process launches) instead of one catch-all "queued".
18
+ */
2
19
 
3
20
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
21
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
22
  import {
6
- continuationLabel,
7
23
  formatElapsed,
8
24
  formatTaskSummary,
9
25
  isRunActiveStatus,
@@ -19,28 +35,47 @@ export const SUBAGENTS_WIDGET_ID = "pi-subagents";
19
35
  * the same bound itself, or a wide parallel dispatch floods the editor area. */
20
36
  const MAX_WIDGET_LINES = 10;
21
37
 
22
- /** Keep the elapsed tail visible while clipping the descriptive left side. */
23
- function compactLine(left: string, right: string, width: number): string {
38
+ const SEPARATOR = " · ";
39
+ /** Column gap between the identity block and the run's label. */
40
+ const IDENTITY_GAP = " ";
41
+ /** Splits "what this run is" from "what it is doing right now". */
42
+ const ACTIVITY_SEPARATOR = " — ";
43
+ /** Columns kept for left content before right-tail parts are dropped. */
44
+ const LEFT_MIN_CONTENT = 8;
45
+ /** Minimum useful width for a live-activity fragment. */
46
+ const ACTIVITY_MIN_WIDTH = 6;
47
+
48
+ /** Shared column widths so every visible row lines up. */
49
+ interface ColumnLayout {
50
+ /** Display width of the widest `#id` among rendered roots. */
51
+ idWidth: number;
52
+ /** Display width of the widest agent name (plus resume marker) among them. */
53
+ agentWidth: number;
54
+ }
55
+
56
+ /** Compose one widget line with a right-aligned telemetry column: the left
57
+ * side truncates first, the right side stays put so elapsed times and badges
58
+ * line up across rows. */
59
+ function layoutLine(left: string, right: string, width: number): string {
24
60
  if (width <= 0) return "";
25
- if (!right) return truncateToWidth(left, width, "");
26
- const separator = " ";
61
+ if (!right) return truncateToWidth(left, width, "");
27
62
  const rightWidth = visibleWidth(right);
28
63
  if (rightWidth >= width) return truncateToWidth(right, width, "");
29
- const leftWidth = width - rightWidth - visibleWidth(separator);
30
- if (leftWidth <= 0) return truncateToWidth(right, width, "");
31
- return `${truncateToWidth(left, leftWidth, "…")}${separator}${right}`;
64
+ const leftBudget = width - rightWidth - 1;
65
+ const leftText = visibleWidth(left) > leftBudget ? truncateToWidth(left, leftBudget, "") : left;
66
+ return `${leftText}${" ".repeat(Math.max(1, width - visibleWidth(leftText) - rightWidth))}${right}`;
32
67
  }
33
68
 
34
- /** Keep continuation semantics intact while independently compacting the task. */
35
- function formatContinuationTask(label: string, task: string, width: number): string {
36
- if (width <= 0) return "";
37
- const labelWidth = visibleWidth(label);
38
- if (labelWidth >= width) return truncateToWidth(label, width, "");
39
- const separator = " · ";
40
- const taskWidth = width - labelWidth - visibleWidth(separator);
41
- if (taskWidth <= 0) return label;
42
- const summary = formatTaskSummary(task, taskWidth);
43
- return summary ? `${label}${separator}${summary}` : label;
69
+ /** Short truthful wait word for a queued row, shown in the telemetry column. */
70
+ function waitWord(run: Pick<RunView, "waitReason">): string {
71
+ switch (run.waitReason) {
72
+ case "repository-lane":
73
+ return "repo lane";
74
+ case "starting":
75
+ return "starting";
76
+ default:
77
+ return "queued";
78
+ }
44
79
  }
45
80
 
46
81
  /** Worktree-group badge shown on the row that owns the isolated worktree: the
@@ -63,107 +98,94 @@ function worktreeBadge(run: RunView): string {
63
98
  }
64
99
  }
65
100
 
66
- /** One compact primary line per genuinely active run, plus an optional indented
67
- * activity line. The primary line reserves stage model/thinking when present and
68
- * elapsed width before truncating the task. Settled and parked threads never
69
- * appear, so elapsed time cannot keep ticking beside a terminal status. */
70
- function runPrimaryLine(
71
- run: RunView,
72
- theme: Theme,
73
- width: number,
74
- now: number,
75
- prefix: string,
76
- isGroupOwner: boolean,
77
- ): string {
78
- const dim = (text: string): string => theme.fg("dim", text);
101
+ /** Drop lower-priority tail parts (leftmost first) until the tail fits. */
102
+ function composeTail(parts: Array<string | undefined>, budget: number): string {
103
+ const present = parts.filter((part): part is string => Boolean(part));
104
+ while (present.length > 0 && visibleWidth(present.join(SEPARATOR)) > budget) present.shift();
105
+ return present.join(SEPARATOR);
106
+ }
107
+
108
+ /** Marker text that shares the agent column so alignment survives resumes. */
109
+ function agentColumnText(run: RunView): string {
110
+ return run.continuationKind ? `${run.agent} ↻` : run.agent;
111
+ }
112
+
113
+ /** `icon #id agent` in fixed columns never truncated. The id is
114
+ * right-aligned and the agent column is padded so every label starts at the
115
+ * same x; a resumed thread carries a dim `↻` inside the agent column. */
116
+ function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): string {
79
117
  const icon = run.managedWorkflow && run.status === "running"
80
118
  ? theme.fg("accent", theme.bold("◆"))
81
119
  : statusIcon(run.status, theme);
82
- const displayName = run.managedWorkflow ? `${run.agent} workflow` : run.agent;
83
- const name = theme.fg("accent", theme.bold(displayName));
84
- // The stable run id is the handle for subagent_control/subagent_status; a
85
- // queued run has not started, which must be visible at a glance. Its model
86
- // is omitted too: the route is re-resolved when the run actually starts.
87
- const queued = run.status === "queued";
88
- const queuedTag = queued ? ` ${dim("· queued")}` : "";
89
- const identity = `${prefix}${icon} #${run.id} ${name}${queuedTag}`;
120
+ const id = `#${run.id}`.padStart(layout.idWidth);
121
+ const name = theme.fg("accent", theme.bold(run.agent));
122
+ const resumed = run.continuationKind ? ` ${theme.fg("dim", "↻")}` : "";
123
+ const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth(agentColumnText(run))));
124
+ return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
125
+ }
126
+
127
+ /** One primary line per run. Left: identity label — activity, where the
128
+ * label stays plain and the live activity is dim. Right: one dim telemetry
129
+ * column (worktree badge, model/thinking, wait state, elapsed). The activity
130
+ * outranks the label when space runs out; the identity and elapsed survive
131
+ * every width. */
132
+ function primaryLine(run: RunView, theme: Theme, width: number, now: number, layout: ColumnLayout): string {
133
+ const dim = (text: string): string => theme.fg("dim", text);
134
+ const identity = identitySegment(run, theme, layout);
90
135
  const elapsed = formatElapsed(run, now);
91
- // Render only the resolved model id plus thinking level. Provider auth and
92
- // other configuration never enter monitor state or this line.
93
136
  const modelId = run.model?.split("/").at(-1);
94
- const modelSource = run.managedWorkflow || queued
95
- ? ""
96
- : formatTaskSummary(
97
- modelId ? `${modelId}${run.thinking ? `/${run.thinking}` : ""}` : run.thinking ? `thinking:${run.thinking}` : "",
98
- 64,
99
- false,
100
- );
101
- const badge = isGroupOwner && run.isolation === "worktree" ? worktreeBadge(run) : "";
102
- // A chain child shows its role in the chain plus a task-derived label; the
103
- // templated fix brief itself would only repeat the parent review's content.
104
- const continuation = run.parentRunId === undefined
105
- ? continuationLabel(run.continuationKind)
137
+ // Queued rows omit the model (the route is re-resolved at actual start);
138
+ // workflow parents omit it too (each stage owns its own model).
139
+ const modelPart = run.status === "queued" || run.managedWorkflow || !modelId
140
+ ? undefined
141
+ : `${modelId}${run.thinking ? `/${run.thinking}` : ""}`;
142
+ const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
143
+ const wait = run.status === "queued" ? waitWord(run) : undefined;
144
+ const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
145
+ // Drop order under pressure: badge, then model, then wait word; elapsed
146
+ // survives every width the identity leaves room for.
147
+ const tail = composeTail([badge, modelPart, wait, elapsed || undefined], tailBudget);
148
+
149
+ // A chain child rendered at root level (its parent row is gone) keeps its
150
+ // workflow relation; the templated brief itself would only repeat content.
151
+ const label = run.parentRunId !== undefined
152
+ ? [run.relationLabel, run.label].filter((part): part is string => Boolean(part)).join(SEPARATOR)
153
+ : run.label ?? formatTaskSummary(run.task, 48);
154
+ // The parent's own activity is a placeholder while a managed workflow runs;
155
+ // the timeline line below carries the live stage instead.
156
+ const activity = !run.managedWorkflow && (run.status === "running" || run.status === "interrupting")
157
+ ? run.activity?.trim()
106
158
  : undefined;
107
- const taskSource = run.parentRunId !== undefined
108
- ? [run.relationLabel, run.label].filter((part): part is string => Boolean(part)).join(" · ")
109
- : run.managedWorkflow
110
- ? run.label ?? formatTaskSummary(run.task, 64)
111
- : formatTaskSummary(run.task, 64);
112
- const taskDesiredSource = [continuation, taskSource]
113
- .filter((part): part is string => Boolean(part))
114
- .join(" · ");
115
- const primaryPartCount = 2 + (modelSource ? 1 : 0) + (badge ? 1 : 0) + (elapsed ? 1 : 0);
116
- const contentWidth = Math.max(
117
- 0,
118
- width -
119
- visibleWidth(identity) -
120
- visibleWidth(elapsed) -
121
- (primaryPartCount - 1) * visibleWidth(" · "),
122
- );
123
- const modelDesired = visibleWidth(modelSource);
124
- const modelFloor = Math.min(modelDesired, Math.min(12, contentWidth));
125
- let modelWidth = 0;
126
- if (modelSource) {
127
- if (continuation) {
128
- const continuationWidth = visibleWidth(continuation);
129
- // Preserve the full semantic label and the usual eight-column task tail
130
- // before giving the remaining space to model/thinking.
131
- const taskFloor = Math.min(
132
- visibleWidth(taskDesiredSource),
133
- continuationWidth +
134
- (taskSource ? visibleWidth(" · ") + Math.min(8, visibleWidth(taskSource)) : 0),
135
- contentWidth,
136
- );
137
- const effectiveModelFloor = Math.min(
138
- modelFloor,
139
- Math.max(0, contentWidth - continuationWidth),
140
- );
141
- modelWidth = Math.min(
142
- modelDesired,
143
- Math.max(effectiveModelFloor, contentWidth - taskFloor),
144
- );
159
+
160
+ const contentBudget = width
161
+ - visibleWidth(identity)
162
+ - (tail ? visibleWidth(tail) + 1 : 0)
163
+ - visibleWidth(IDENTITY_GAP);
164
+ let content = "";
165
+ if (contentBudget > 0) {
166
+ const labelBudget = activity
167
+ ? Math.min(visibleWidth(label), Math.max(12, Math.floor(contentBudget * 0.4)))
168
+ : contentBudget;
169
+ // The label is already fragment-extracted (runLabel); plain right
170
+ // truncation avoids stacking a second head…tail ellipsis on top of it.
171
+ const labelText = label
172
+ ? visibleWidth(label) > labelBudget ? truncateToWidth(label, labelBudget, "…") : label
173
+ : "";
174
+ if (activity) {
175
+ const activityBudget = contentBudget
176
+ - visibleWidth(labelText)
177
+ - (labelText ? visibleWidth(ACTIVITY_SEPARATOR) : 0);
178
+ content = activityBudget >= ACTIVITY_MIN_WIDTH
179
+ ? `${labelText}${labelText ? dim(ACTIVITY_SEPARATOR) : ""}${dim(formatTaskSummary(activity, activityBudget))}`
180
+ // Too narrow for both: the live activity is the stronger signal.
181
+ : dim(formatTaskSummary(activity, contentBudget));
145
182
  } else {
146
- modelWidth = Math.min(modelDesired, Math.max(modelFloor, contentWidth - 8));
183
+ content = labelText;
147
184
  }
148
185
  }
149
- let taskWidth = contentWidth - modelWidth;
150
- if (visibleWidth(taskDesiredSource) < taskWidth) {
151
- modelWidth = Math.min(modelDesired, modelWidth + taskWidth - visibleWidth(taskDesiredSource));
152
- taskWidth = contentWidth - modelWidth;
153
- }
154
- const task = taskWidth <= 0
155
- ? ""
156
- : continuation
157
- ? formatContinuationTask(continuation, taskSource, taskWidth)
158
- : formatTaskSummary(taskSource, taskWidth, run.parentRunId === undefined);
159
- const modelThinking = modelWidth > 0 ? formatTaskSummary(modelSource, modelWidth, false) : "";
160
- const primaryLeft = [
161
- identity,
162
- task ? dim(task) : undefined,
163
- modelThinking ? dim(modelThinking) : undefined,
164
- badge ? dim(badge) : undefined,
165
- ].filter((part): part is string => Boolean(part)).join(" · ");
166
- return compactLine(primaryLeft, elapsed ? dim(`· ${elapsed}`) : "", width);
186
+
187
+ const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
188
+ return layoutLine(left, tail ? dim(tail) : "", width);
167
189
  }
168
190
 
169
191
  function workflowStageToken(stage: WorkflowStage, theme: Theme): string {
@@ -182,20 +204,14 @@ function workflowStageToken(stage: WorkflowStage, theme: Theme): string {
182
204
  }
183
205
  }
184
206
 
185
- /** Render the real/currently planned stage sequence. On narrow terminals the
186
- * active (or next actionable) stage becomes the left edge so it survives before
187
- * older history; the workflow-wide elapsed tail is independently preserved on
188
- * the primary line. */
189
- function workflowTimelineLine(run: RunView, theme: Theme, width: number): string | undefined {
190
- const stages = run.workflowStages;
191
- const indent = " ";
192
- if (!stages || stages.length === 0 || width <= visibleWidth(indent)) return undefined;
207
+ /** Stage timeline, sliced from the active (or next actionable) stage when the
208
+ * full sequence does not fit, so the current position always survives. */
209
+ function timelineSegment(stages: readonly WorkflowStage[], theme: Theme, width: number): string {
193
210
  const separator = theme.fg("dim", " ─ ");
194
211
  const render = (items: readonly WorkflowStage[]): string =>
195
212
  items.map((stage) => workflowStageToken(stage, theme)).join(separator);
196
- const full = `${indent}${render(stages)}`;
213
+ const full = render(stages);
197
214
  if (visibleWidth(full) <= width) return full;
198
-
199
215
  let focusIndex = stages.findIndex((stage) => stage.status === "active");
200
216
  if (focusIndex === -1) {
201
217
  focusIndex = stages.findLastIndex((stage) => stage.status === "changes" || stage.status === "failed");
@@ -203,24 +219,45 @@ function workflowTimelineLine(run: RunView, theme: Theme, width: number): string
203
219
  if (focusIndex === -1) focusIndex = stages.findIndex((stage) => stage.status === "pending");
204
220
  if (focusIndex === -1) focusIndex = stages.length - 1;
205
221
  const omittedPrefix = focusIndex > 0 ? theme.fg("dim", "… ─ ") : "";
206
- return truncateToWidth(`${indent}${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
222
+ return truncateToWidth(`${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
207
223
  }
208
224
 
209
- function runActivityLine(run: RunView, theme: Theme, width: number, indent: string): string[] {
210
- const dim = (text: string): string => theme.fg("dim", text);
211
- const activity = run.activity?.trim();
212
- if (!activity) return [];
213
- const activityWidth = width - visibleWidth(indent);
214
- if (activityWidth <= 0) return [];
215
- const activitySummary = formatTaskSummary(activity, activityWidth);
216
- if (!activitySummary) return [];
217
- return [truncateToWidth(`${indent}${dim(activitySummary)}`, width, "")];
225
+ /** Timeline line under a managed workflow parent, tied to it with a dim `└`.
226
+ * The live stage's telemetry (its activity or model, plus stage elapsed) rides
227
+ * right-aligned, so the two workflow lines replace what used to be four
228
+ * (parent, timeline, child row, child activity). */
229
+ function workflowTimelineLine(
230
+ run: RunView,
231
+ children: readonly RunView[],
232
+ theme: Theme,
233
+ width: number,
234
+ now: number,
235
+ ): string | undefined {
236
+ const stages = run.workflowStages;
237
+ const indent = ` ${theme.fg("dim", "└")} `;
238
+ const budget = width - visibleWidth(indent);
239
+ if (!stages || stages.length === 0 || budget <= 0) return undefined;
240
+ const child = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
241
+ ?? children.at(-1);
242
+ const childDoing = child?.activity?.trim() || child?.model?.split("/").at(-1) || "";
243
+ const childElapsed = child ? formatElapsed(child, now) : "";
244
+ const timeline = timelineSegment(stages, theme, budget);
245
+ const room = budget - visibleWidth(timeline) - 1;
246
+ let tail = "";
247
+ if (room >= ACTIVITY_MIN_WIDTH) {
248
+ const doingBudget = room - (childElapsed ? visibleWidth(childElapsed) + (childDoing ? visibleWidth(SEPARATOR) : 0) : 0);
249
+ const doingText = childDoing && doingBudget >= ACTIVITY_MIN_WIDTH
250
+ ? formatTaskSummary(childDoing, doingBudget)
251
+ : "";
252
+ const parts = [doingText, childElapsed].filter(Boolean);
253
+ if (parts.length > 0) tail = theme.fg("dim", parts.join(SEPARATOR));
254
+ }
255
+ return `${indent}${layoutLine(timeline, tail, budget)}`;
218
256
  }
219
257
 
220
- /** Render active runs as compact workflow-aware trees. Stable managed parents
221
- * retain their stage timeline while the current internal child supplies exact
222
- * model/thinking/activity telemetry. Control ids remain available through
223
- * status. */
258
+ /** Render active runs as compact per-run line groups: one line per simple run,
259
+ * two per managed workflow. Internal stage children fold into their parent's
260
+ * timeline instead of adding rows, and all rows share one column layout. */
224
261
  export function formatActiveRunLines(
225
262
  runs: readonly RunView[],
226
263
  theme: Theme,
@@ -240,30 +277,31 @@ export function formatActiveRunLines(
240
277
  roots.push(run);
241
278
  }
242
279
  }
243
- const lines: string[] = [];
244
- for (const root of roots) {
245
- const children = childrenOf.get(root.id) ?? [];
246
- // Roots (including orphaned chain children whose parent row is gone) own
247
- // their worktree group; nested children inherit the group via the tree.
248
- lines.push(runPrimaryLine(root, theme, width, now, "", true));
249
- const hasTimeline = Boolean(root.managedWorkflow && root.workflowStages?.length);
250
- const timeline = hasTimeline ? workflowTimelineLine(root, theme, width) : undefined;
280
+ const layout: ColumnLayout = {
281
+ idWidth: Math.max(...roots.map((root) => visibleWidth(`#${root.id}`)), 0),
282
+ agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), 0),
283
+ };
284
+ const groups: string[][] = roots.map((root) => {
285
+ const lines = [primaryLine(root, theme, width, now, layout)];
286
+ const timeline = root.managedWorkflow
287
+ ? workflowTimelineLine(root, childrenOf.get(root.id) ?? [], theme, width, now)
288
+ : undefined;
251
289
  if (timeline) lines.push(timeline);
252
- // A parent placeholder ("managed workflow running") would duplicate the
253
- // timeline. Standalone roots keep their useful activity line as before.
254
- if (children.length === 0 && !hasTimeline) {
255
- lines.push(...runActivityLine(root, theme, width, " "));
256
- }
257
- children.forEach((child, index) => {
258
- const connector = index === children.length - 1 ? "└ " : "├ ";
259
- lines.push(runPrimaryLine(child, theme, width, now, theme.fg("dim", ` ${connector}`), false));
260
- lines.push(...runActivityLine(child, theme, width, " "));
261
- });
290
+ return lines;
291
+ });
292
+
293
+ const lines: string[] = [];
294
+ let shownRoots = 0;
295
+ for (const group of groups) {
296
+ const remaining = MAX_WIDGET_LINES - 1 - lines.length;
297
+ if (remaining <= 0) break;
298
+ // A group that no longer fits whole keeps its primary line only.
299
+ lines.push(...(group.length <= remaining ? group : group.slice(0, remaining)));
300
+ shownRoots++;
262
301
  }
263
- const hidden = lines.length - (MAX_WIDGET_LINES - 1);
264
- if (hidden > 0) {
265
- lines.length = MAX_WIDGET_LINES - 1;
266
- lines.push(theme.fg("dim", `… +${hidden} more (subagent_status)`));
302
+ const hiddenRoots = roots.length - shownRoots;
303
+ if (hiddenRoots > 0) {
304
+ lines.push(theme.fg("dim", `… +${hiddenRoots} more`));
267
305
  }
268
306
  return lines;
269
307
  }
package/src/workflow.ts CHANGED
@@ -35,6 +35,12 @@ export interface ManagedWorkflowPlan {
35
35
  initialRelation: string;
36
36
  }
37
37
 
38
+ /** Dispatch-time gate intensity for one worker/cleaner task. "gate" (default)
39
+ * keeps the automatic post-writer reviewer; "none" skips it so a mechanical,
40
+ * low-risk edit does not pay for a full adversarial review — the dispatching
41
+ * model owns that proportionality call because it knows the task's risk. */
42
+ export type ReviewMode = "gate" | "none";
43
+
38
44
  /** Fixed cap on reviewer fix → re-review rounds inside one managed workflow.
39
45
  * Re-reviews converge by construction (they verify fixes and fix regressions
40
46
  * instead of re-scanning the whole surface); the cap only stops pathological
@@ -62,11 +68,22 @@ export function canStartManagedWorkflow(
62
68
  /** Classify only healthy top-level writer results; everything else delivers
63
69
  * directly, including every reviewer result — a direct reviewer dispatch never
64
70
  * starts another child. A failing managed gate is expanded by the workflow
65
- * itself into the reviewer fix stage. */
71
+ * itself into the reviewer fix stage. A dispatch that opted out of the gate
72
+ * (review: "none") delivers directly too.
73
+ *
74
+ * `changedWorkspace: false` also delivers directly. A gate reviews a pending
75
+ * diff, so a run that produced none — a cleaner that found no safe cut, a worker
76
+ * that concluded there was nothing to do, both explicitly valid outcomes — has
77
+ * nothing to review, and spending a reviewer on an empty diff buys nothing.
78
+ * Only a proven absence of changes skips: `undefined` keeps the gate. */
66
79
  export function getManagedWorkflowPlan(
67
80
  result: SingleResult,
68
81
  availability: WorkflowAgentAvailability,
82
+ review: ReviewMode = "gate",
83
+ changedWorkspace?: boolean,
69
84
  ): ManagedWorkflowPlan | undefined {
85
+ if (review === "none") return undefined;
86
+ if (changedWorkspace === false) return undefined;
70
87
  if (result.dispatchFailed || isFailedResult(result)) return undefined;
71
88
  if (result.agent === "worker" || result.agent === "cleaner") {
72
89
  if (!availability.reviewer) return undefined;
@@ -89,6 +106,8 @@ export function buildFinalReviewBrief(initialResult: SingleResult): string {
89
106
  `---`,
90
107
  ``,
91
108
  `Run \`git status\` and \`git diff\` and judge the actual pending code; the report is context, not proof.`,
109
+ `Scale the gate to the change: a small, contained diff gets a fast, focused review of its correctness,`,
110
+ `regressions, and blast radius — not a whole-surface audit or a redesign of surrounding code it merely touches.`,
92
111
  `Remain read-only. Attach a concrete fix instruction to EVERY gate finding — including documentation drift —:`,
93
112
  `what to change, where, and how to verify it. A failing gate continues into your own write-enabled fix stage,`,
94
113
  `so make every instruction executable exactly as written.`,
@@ -145,11 +164,10 @@ export function buildReReviewBrief(fixResult: SingleResult, round: number): stri
145
164
  }
146
165
 
147
166
  /**
148
- * One step of a managed workflow as delivered: the run id (so the condensed
149
- * summary can point at per-run detail via subagent_status), the result, and
150
- * the human-readable role within the workflow ("initial implementation",
151
- * "final review"). runId is optional only for synthetic steps that never
152
- * spawned a child.
167
+ * One step of a managed workflow as delivered: the run id (the stable handle
168
+ * for resume/stop), the result, and the human-readable role within the
169
+ * workflow ("initial implementation", "final review"). runId is optional only
170
+ * for synthetic steps that never spawned a child.
153
171
  */
154
172
  export interface ChainStep {
155
173
  runId?: number;
@@ -182,8 +200,6 @@ function appendWorkflowFooter(lines: string[], steps: readonly ChainStep[]): voi
182
200
  const total = sumUsage(steps.map((step) => step.result.usage));
183
201
  const usage = formatUsageCompact(total);
184
202
  lines.push("", `Totals: ${steps.length} run${steps.length === 1 ? "" : "s"}${usage ? ` · ${usage}` : ""}`);
185
- const ids = steps.filter((step) => step.runId !== undefined).map((step) => `#${step.runId}`);
186
- lines.push(`Per-run details: subagent_status ${ids.join(" ")}`);
187
203
  }
188
204
 
189
205
  /** One clear final delivery for managed writer → gate workflows. */
package/src/worktree.ts CHANGED
@@ -12,6 +12,7 @@ import { spawn, type ChildProcess } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
13
  import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
14
14
  import { isAbsolute, join, relative, resolve } from "node:path";
15
+ import { writeTempOwnerMarker } from "./temp-hygiene.ts";
15
16
 
16
17
  export type IsolationMode = "shared" | "worktree";
17
18
 
@@ -257,6 +258,10 @@ export interface WorktreeIsolation {
257
258
  snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
258
259
  /** Remove a newly-created continuation that failed before it was dispatched. */
259
260
  discard(): Promise<void>;
261
+ /** Whether anything is pending against the integration base — the same
262
+ * question finalization answers as `hadChanges`, asked before settlement so
263
+ * policy can tell a run that produced a diff from one that produced none. */
264
+ hasPendingChanges(): Promise<boolean>;
260
265
  /** Idempotent across stale generations and repeated stop/shutdown paths. */
261
266
  finalize(): Promise<WorktreeFinalization>;
262
267
  }
@@ -467,6 +472,16 @@ class GitWorktreeIsolation implements WorktreeIsolation {
467
472
  return this.discardPromise;
468
473
  }
469
474
 
475
+ async hasPendingChanges(): Promise<boolean> {
476
+ // A settled worktree already recorded the answer; asking Git again after
477
+ // removal would fail. Diffing the integration base (not HEAD) keeps a
478
+ // continuation honest: only this generation's own work counts.
479
+ if (this.currentState === "no_changes") return false;
480
+ if (this.currentState !== "active" || !existsSync(this.worktreePath)) return true;
481
+ const diff = await this.collectChanges(this.integrationBaseHead);
482
+ return diff.stdout.length > 0;
483
+ }
484
+
470
485
  finalize(): Promise<WorktreeFinalization> {
471
486
  if (this.finalization) return this.finalization;
472
487
  this.currentState = "finalizing";
@@ -679,6 +694,9 @@ export async function createWorktreeIsolation(
679
694
  const tempBase = resolve(options.tempBaseDir);
680
695
  await mkdir(tempBase, { recursive: true });
681
696
  const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
697
+ // Ownership is how a later load tells a worktree still in use from one a
698
+ // crash abandoned, since neither has a durable record until it checkpoints.
699
+ writeTempOwnerMarker(tempDir);
682
700
  const worktreePath = join(tempDir, "worktree");
683
701
  const patchPath = join(tempDir, "changes.patch");
684
702
  let added = false;