@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/README.md +384 -337
- package/agents/cleaner.md +50 -45
- package/agents/documenter.md +40 -42
- package/agents/explorer.md +40 -45
- package/agents/reviewer.md +82 -82
- package/agents/synthesizer.md +39 -0
- package/agents/worker.md +43 -45
- package/package.json +55 -55
- package/src/agents.ts +25 -5
- package/src/announcements.ts +78 -75
- package/src/background.ts +11 -0
- package/src/completion.ts +19 -9
- package/src/config.ts +3 -10
- package/src/dispatch.ts +817 -647
- package/src/durable.ts +443 -402
- package/src/format.ts +173 -179
- package/src/index.ts +6 -6
- package/src/models.ts +4 -6
- package/src/monitor.ts +56 -5
- package/src/prompt.ts +14 -21
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +993 -993
- package/src/runtime.ts +22 -4
- package/src/session-fork.ts +2 -0
- package/src/setup.ts +23 -43
- package/src/spawn.ts +668 -654
- package/src/temp-hygiene.ts +230 -174
- package/src/thread-lifecycle.ts +1487 -1399
- package/src/tools.ts +384 -712
- package/src/widget.ts +195 -157
- package/src/workflow.ts +24 -8
- package/src/worktree.ts +18 -0
package/src/widget.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
/**
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
return `${
|
|
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
|
-
/**
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
83
|
-
const name = theme.fg("accent", theme.bold(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
visibleWidth(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
183
|
+
content = labelText;
|
|
147
184
|
}
|
|
148
185
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
/**
|
|
186
|
-
*
|
|
187
|
-
|
|
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 =
|
|
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(`${
|
|
222
|
+
return truncateToWidth(`${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
|
|
207
223
|
}
|
|
208
224
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
lines
|
|
249
|
-
const
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
264
|
-
if (
|
|
265
|
-
lines.
|
|
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 (
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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;
|