pi-better-harness 0.29.0 → 0.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/extensions/minimal-output/hook.ts +3 -1
- package/extensions/minimal-output/tool-identity.ts +12 -8
- package/node_modules/pi-better-goal/README.md +2 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +29 -0
- package/node_modules/pi-better-goal/src/rehydrate.ts +57 -0
- package/node_modules/pi-better-plan/README.md +1 -1
- package/node_modules/pi-better-plan/package.json +1 -1
- package/node_modules/pi-better-plan/src/cancelled-rows.ts +36 -0
- package/node_modules/pi-better-plan/src/index.ts +59 -17
- package/node_modules/pi-better-plan/src/plan-presentation.ts +13 -4
- package/node_modules/pi-better-plan/src/workflow-plan.ts +14 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -69,8 +69,8 @@ The bundled harness provides **Tool output: Normal / Minimal** in
|
|
|
69
69
|
`/tool-output` (toggle) remain compatibility shortcuts to the same preference.
|
|
70
70
|
Minimal mode folds each tool call into a single-line header, including running
|
|
71
71
|
calls. The tool-specific icon and tool name share a state color: accent while
|
|
72
|
-
running, muted when completed, and error-colored when failed. In fullscreen Pi, a soft highlight travels through visible running
|
|
73
|
-
command/path
|
|
72
|
+
running, muted when completed, and error-colored when failed. In fullscreen Pi, a soft highlight travels through the entire visible running tool name and its
|
|
73
|
+
inline command/path details; details stay dim between highlight passes. Scrollback terminals keep static state colors.
|
|
74
74
|
Choose **Tool animation: Off** in `/harness-settings` for static running headers.
|
|
75
75
|
Running and failed calls retain text labels so state never depends on color or
|
|
76
76
|
motion alone. Tool rows are indented two columns past
|
|
@@ -174,8 +174,10 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
|
|
|
174
174
|
const suffix = failed ? " (failed)" : running ? " (running)" : "";
|
|
175
175
|
const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
|
|
176
176
|
const title = ` ${icon} ${label}`;
|
|
177
|
+
const visibleDetail = animationTime === undefined ? detail : truncateToWidth(detail,
|
|
178
|
+
Math.max(0, width - Math.min(indent, Math.max(0, width - 1)) - visibleWidth(title) - 2));
|
|
177
179
|
const styled = theme
|
|
178
|
-
? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime
|
|
180
|
+
? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime, visibleDetail)}${theme.fg(failed ? "error" : "dim", suffix)}`
|
|
179
181
|
: `${title}${detail ? ` ${detail}` : ""}${suffix}`;
|
|
180
182
|
return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
|
|
181
183
|
}
|
|
@@ -6,7 +6,7 @@ const concreteColors = (theme: Theme) => (theme as Theme & { colors?: Record<str
|
|
|
6
6
|
const PERIOD = 2400;
|
|
7
7
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
8
8
|
|
|
9
|
-
function themeRgb(theme: Theme, token: "accent" | "text"): RGB | undefined {
|
|
9
|
+
function themeRgb(theme: Theme, token: "accent" | "text" | "dim"): RGB | undefined {
|
|
10
10
|
const color = concreteColors(theme)?.[token];
|
|
11
11
|
if (color?.kind === "rgb" && color.r !== undefined && color.g !== undefined && color.b !== undefined) {
|
|
12
12
|
return [color.r, color.g, color.b];
|
|
@@ -43,23 +43,27 @@ function foreground(color: RGB, mode: "truecolor" | "256color"): string {
|
|
|
43
43
|
return `\x1b[38;5;${index}m`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function toolIdentity(icon: string, label: string, tone: "accent" | "muted" | "error", theme: Theme, time?: number): string {
|
|
47
|
-
|
|
46
|
+
export function toolIdentity(icon: string, label: string, tone: "accent" | "muted" | "error", theme: Theme, time?: number, detail = ""): string {
|
|
47
|
+
const staticHeader = () => theme.fg(tone, `${icon} ${label}`) + (detail ? ` ${theme.fg("dim", detail)}` : "");
|
|
48
|
+
if (tone !== "accent" || time === undefined || !supportsToolShimmer(theme)) return staticHeader();
|
|
48
49
|
const base = themeRgb(theme, "accent");
|
|
49
50
|
const light = themeRgb(theme, "text");
|
|
50
|
-
|
|
51
|
-
const
|
|
51
|
+
const letters = Array.from(graphemes.segment(label + (detail ? ` ${detail}` : "")), part => part.segment);
|
|
52
|
+
const nameLength = Array.from(graphemes.segment(label)).length;
|
|
52
53
|
const radius = Math.max(2, Math.min(4, letters.length / 3));
|
|
53
54
|
const position = (time % PERIOD) / PERIOD * (letters.length + radius * 2) - radius;
|
|
54
55
|
const mode = theme.getColorMode();
|
|
56
|
+
const detailBase = detail ? themeRgb(theme, "dim") : undefined;
|
|
55
57
|
const name = letters.map((letter, index) => {
|
|
58
|
+
const token = index < nameLength ? "accent" : "dim";
|
|
59
|
+
const letterBase = token === "accent" ? base : detailBase;
|
|
56
60
|
const distance = Math.abs(index - position) / radius;
|
|
57
61
|
const shine = distance < 1 ? (1 + Math.cos(distance * Math.PI)) * 0.48 : 0;
|
|
58
|
-
if (!base || !light) {
|
|
62
|
+
if (!base || !light || !letterBase) {
|
|
59
63
|
const intensity = shine > 0.72 ? "\x1b[1m" : shine > 0.18 ? "" : "\x1b[2m";
|
|
60
|
-
return
|
|
64
|
+
return `${theme.getFgAnsi(token)}\x1b[22m${intensity}${letter}`;
|
|
61
65
|
}
|
|
62
|
-
const color =
|
|
66
|
+
const color = letterBase.map((value, channel) => Math.round(value + (light[channel] - value) * shine)) as RGB;
|
|
63
67
|
return `${foreground(color, mode)}${letter}`;
|
|
64
68
|
}).join("");
|
|
65
69
|
return theme.fg(tone, `${icon} \x1b[22m${name}\x1b[22m`);
|
|
@@ -55,6 +55,8 @@ metadata:
|
|
|
55
55
|
|
|
56
56
|
Invoking the alias (`/skill:resolve-issues`, or `/goal /skill:resolve-issues task`) records the target coordinator, with the target's registered path, as the workflow owner, so its task plan, continuation instructions, and plan tools behave exactly as if the target had been invoked. The target must be a registered `workflow-role: coordinator` skill; an unregistered target, a non-coordinator target, or a chain of aliases is refused with a notice.
|
|
57
57
|
|
|
58
|
+
After every successful context compaction (Pi's `session_compact` event) while a coordinator owns the session, the goal extension re-injects the skill so the model sees it before its next step; the compaction summary keeps run state but not the skill's rules, and the summary itself is left unchanged. If the skill directory ships `scripts/rehydrate.mjs` (checked first) or `rehydrate.mjs`, it runs as `node <script> --cwd <session cwd>` (no shell, 10-second timeout, stderr ignored) and its trimmed stdout becomes the message; empty output injects nothing, so the script decides whether a run is in progress. Without a script, or when it fails or times out, the message is: "Your context was just compacted while you coordinate the `<name>` workflow. Before your next step, read `<SKILL.md path>` in full, with no line limit." The message is a `workflow-rehydrate` custom message: inside a running agent loop (or before an overflow retry) it steers the next model call; otherwise it is appended without starting a turn.
|
|
59
|
+
|
|
58
60
|
When `pi-better-plan` is installed, it defers its prompt, checklist, and `update_plan` tool to a skill-owned task plan. Skills without this metadata retain normal goal and plan behavior. The skill itself owns its planning format and worker policy; the harness does not enumerate skills or impose a shared workflow schema. The former `pi-better-plan-workflow: coordinator` metadata remains supported for installed skills.
|
|
59
61
|
|
|
60
62
|
## Install
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { createRenderScheduler } from "./shared-render-scheduler.js";
|
|
41
41
|
import { collectSubagentActivity } from "./subagents.js";
|
|
42
42
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
43
|
+
import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
|
|
43
44
|
import {
|
|
44
45
|
EVENT_ACTIVITY,
|
|
45
46
|
EVENT_READY,
|
|
@@ -312,6 +313,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
312
313
|
let snapshotSequence = 0;
|
|
313
314
|
let lastAppliedSnapshotSequence = 0;
|
|
314
315
|
let foregroundRunning = false;
|
|
316
|
+
/** True between agent_start and agent_end: the agent loop will call the model again without a new prompt. */
|
|
317
|
+
let agentLoopActive = false;
|
|
315
318
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
316
319
|
let collecting = false;
|
|
317
320
|
let collectionPending = false;
|
|
@@ -1186,6 +1189,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1186
1189
|
collectionPending = false;
|
|
1187
1190
|
currentCtx = ctx;
|
|
1188
1191
|
foregroundRunning = !ctx.isIdle();
|
|
1192
|
+
agentLoopActive = false;
|
|
1189
1193
|
try {
|
|
1190
1194
|
preferences = readGoalPreferences();
|
|
1191
1195
|
} catch (error) {
|
|
@@ -1334,6 +1338,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1334
1338
|
agentGeneration = executionGeneration;
|
|
1335
1339
|
currentCtx = ctx;
|
|
1336
1340
|
foregroundRunning = true;
|
|
1341
|
+
agentLoopActive = true;
|
|
1337
1342
|
clearIdleContinuation();
|
|
1338
1343
|
continuationQueuedFor = null;
|
|
1339
1344
|
lastAgentEvidence = null;
|
|
@@ -1355,6 +1360,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1355
1360
|
});
|
|
1356
1361
|
|
|
1357
1362
|
pi.on("agent_end", async (event, ctx) => {
|
|
1363
|
+
agentLoopActive = false;
|
|
1358
1364
|
lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
|
|
1359
1365
|
// Only an observed editor Escape with pause disabled exempts this abort.
|
|
1360
1366
|
// Unknown interrupt sources retain the safety fallback on every host.
|
|
@@ -1483,6 +1489,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1483
1489
|
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
1484
1490
|
});
|
|
1485
1491
|
|
|
1492
|
+
// A compaction summary keeps run state but drops the coordinator skill's rules. Re-inject them
|
|
1493
|
+
// (or the skill's own rehydrate script output) so the model sees them before its next step.
|
|
1494
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
1495
|
+
const owner = getWorkflow(ctx);
|
|
1496
|
+
if (!owner) return;
|
|
1497
|
+
const content = await workflowRehydrateMessage(owner, ctx.cwd);
|
|
1498
|
+
if (!content) return;
|
|
1499
|
+
// Inside a running agent loop (mid-run compaction or an overflow retry) steer it into the next
|
|
1500
|
+
// model call. Otherwise append it without triggering a turn: the next turn, whatever starts it,
|
|
1501
|
+
// sees it. Steering after the loop ended would start an unprompted continuation.
|
|
1502
|
+
const options = agentLoopActive || event.willRetry ? { deliverAs: "steer" as const } : { triggerTurn: false };
|
|
1503
|
+
try {
|
|
1504
|
+
pi.sendMessage({
|
|
1505
|
+
customType: WORKFLOW_REHYDRATE_MESSAGE_TYPE,
|
|
1506
|
+
content,
|
|
1507
|
+
display: true,
|
|
1508
|
+
details: { workflow: owner.name, path: owner.path, reason: event.reason },
|
|
1509
|
+
}, options);
|
|
1510
|
+
} catch {
|
|
1511
|
+
// Best effort: never fail or block the session over a rehydrate notice.
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1486
1515
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1487
1516
|
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
1488
1517
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { WorkflowOwner } from "./workflow.js";
|
|
6
|
+
|
|
7
|
+
export const WORKFLOW_REHYDRATE_MESSAGE_TYPE = "workflow-rehydrate";
|
|
8
|
+
export const REHYDRATE_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
/** Script locations a coordinator skill may ship, in precedence order, relative to its SKILL.md directory. */
|
|
11
|
+
const REHYDRATE_SCRIPTS = [join("scripts", "rehydrate.mjs"), "rehydrate.mjs"];
|
|
12
|
+
|
|
13
|
+
export function rehydrateFallbackMessage(owner: WorkflowOwner): string {
|
|
14
|
+
return `Your context was just compacted while you coordinate the ${owner.name} workflow. ` +
|
|
15
|
+
`Before your next step, read ${owner.path} in full, with no line limit.`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first rehydrate script the owner's skill ships, or undefined when it ships none. */
|
|
19
|
+
export function findRehydrateScript(owner: WorkflowOwner): string | undefined {
|
|
20
|
+
const skillDir = dirname(owner.path);
|
|
21
|
+
for (const relative of REHYDRATE_SCRIPTS) {
|
|
22
|
+
const candidate = join(skillDir, relative);
|
|
23
|
+
try {
|
|
24
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
25
|
+
} catch {
|
|
26
|
+
// Missing or unreadable: try the next location.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runScript(script: string, cwd: string, timeoutMs: number): Promise<string> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
execFile("node", [script, "--cwd", cwd], { cwd, timeout: timeoutMs, encoding: "utf8", windowsHide: true },
|
|
35
|
+
(error, stdout) => (error ? reject(error) : resolve(stdout)));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The message to inject after a compaction while `owner` coordinates the session, or null for none.
|
|
41
|
+
* A skill's rehydrate script decides the text (empty output means no run is in progress); without
|
|
42
|
+
* a script, or when it fails or times out, the model is told to re-read SKILL.md. Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export async function workflowRehydrateMessage(
|
|
45
|
+
owner: WorkflowOwner,
|
|
46
|
+
cwd: string,
|
|
47
|
+
options: { timeoutMs?: number } = {},
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
try {
|
|
50
|
+
const script = findRehydrateScript(owner);
|
|
51
|
+
if (!script) return rehydrateFallbackMessage(owner);
|
|
52
|
+
const stdout = (await runScript(script, cwd, options.timeoutMs ?? REHYDRATE_TIMEOUT_MS)).trim();
|
|
53
|
+
return stdout.length > 0 ? stdout : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return rehydrateFallbackMessage(owner);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -16,7 +16,7 @@ By default, you see up to five steps around the current work. The view starts at
|
|
|
16
16
|
|
|
17
17
|
Click the plan to expand or collapse it in Pi's fullscreen mode. In regular terminal mode, use `/plan expand` or `/plan collapse`. For the full plan with details, run `/plan`.
|
|
18
18
|
|
|
19
|
-
Rows show `✓` for done, `●` for active, `○` for pending, and `!` for blocked. Pi marks steps complete explicitly; the count is not an estimate of effort.
|
|
19
|
+
Rows show `✓` for done, `●` for active, `○` for pending, and `!` for blocked. Pi marks steps complete explicitly; the count is not an estimate of effort. The completion count is completed items / total eligible items: canceled workflow items (`cancelled` or `canceled`) are excluded from the total, while skipped and not-applicable items remain in it. Canceled items remain visible for 30 seconds after the cancellation is first observed, then disappear from the widget and plan lists without deleting their workflow records. This delay survives session reloads; reactivating an item makes it visible again. An all-canceled plan shows `0/0` complete.
|
|
20
20
|
|
|
21
21
|
Your plan and display settings survive reload and follow the session branch. A completed checklist clears after 30 seconds. A released workflow plan stays visible for reference, but cannot be edited until the workflow resumes.
|
|
22
22
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CANCELLED_ROW_RETENTION_MS, isRecord, type RushPlan, type WorkflowPlanBinding } from "./workflow-plan.js";
|
|
2
|
+
|
|
3
|
+
export const CANCELLED_ROWS_ENTRY = "pi-better-plan-cancelled-rows";
|
|
4
|
+
|
|
5
|
+
export function observeCancelledRows(
|
|
6
|
+
plan: RushPlan,
|
|
7
|
+
binding: WorkflowPlanBinding,
|
|
8
|
+
entries: Iterable<{ type: string; customType?: string; data?: unknown }>,
|
|
9
|
+
append: (type: string, data: unknown) => void,
|
|
10
|
+
now = Date.now(),
|
|
11
|
+
): void {
|
|
12
|
+
// Keep display deadlines in branch history, not in the workflow-owned task plan.
|
|
13
|
+
let previous: Record<string, unknown> = {};
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const data = entry.data;
|
|
16
|
+
if (entry.type === "custom" && entry.customType === CANCELLED_ROWS_ENTRY && isRecord(data) &&
|
|
17
|
+
data.version === 1 && data.path === binding.path && data.runId === binding.runId &&
|
|
18
|
+
data.owner === binding.owner && isRecord(data.rows)) previous = data.rows;
|
|
19
|
+
}
|
|
20
|
+
const rows: Record<string, number> = Object.create(null);
|
|
21
|
+
for (const unit of plan.issues) {
|
|
22
|
+
if (unit.status !== "cancelled" && unit.status !== "canceled") continue;
|
|
23
|
+
const saved = previous[unit.id];
|
|
24
|
+
unit.cancelledAt = typeof saved === "number" && Number.isFinite(saved) && saved >= 0 ? saved : now;
|
|
25
|
+
rows[unit.id] = unit.cancelledAt;
|
|
26
|
+
}
|
|
27
|
+
if (JSON.stringify(rows) !== JSON.stringify(previous)) {
|
|
28
|
+
append(CANCELLED_ROWS_ENTRY, { version: 1, ...binding, rows });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cancelledRowsClearDelay(plan: RushPlan, now = Date.now()): number | null {
|
|
33
|
+
const deadlines = plan.issues.flatMap(unit => unit.cancelledAt === undefined ? [] : [unit.cancelledAt + CANCELLED_ROW_RETENTION_MS])
|
|
34
|
+
.filter(deadline => deadline > now);
|
|
35
|
+
return deadlines.length ? Math.min(...deadlines) - now : null;
|
|
36
|
+
}
|
|
@@ -18,8 +18,9 @@ import { createPlanWidget } from "./plan-widget.js";
|
|
|
18
18
|
import { COMPACT_PLAN_ROWS } from "./plan-presentation.js";
|
|
19
19
|
import {
|
|
20
20
|
createRushPlanComponent, readRushPlan, renderRushPlan, workflowBinding,
|
|
21
|
-
WORKFLOW_PLAN_ENTRY, type RushPlan, type WorkflowPlanBinding,
|
|
21
|
+
WORKFLOW_PLAN_ENTRY, visibleRushUnits, type RushPlan, type WorkflowPlanBinding,
|
|
22
22
|
} from "./workflow-plan.js";
|
|
23
|
+
import { cancelledRowsClearDelay, observeCancelledRows } from "./cancelled-rows.js";
|
|
23
24
|
import { applyRushPlanUpdate, type RushPlanUpdate } from "./workflow-plan-update.js";
|
|
24
25
|
import {
|
|
25
26
|
EXTENSION_NAME,
|
|
@@ -131,18 +132,45 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
131
132
|
let rushError: string | null = null;
|
|
132
133
|
let refreshWidget: ((force?: boolean) => void) | undefined;
|
|
133
134
|
let completedPlanClearTimer: ReturnType<typeof setTimeout> | undefined;
|
|
135
|
+
let cancelledRowsTimer: ReturnType<typeof setTimeout> | undefined;
|
|
136
|
+
let refreshFullPlan: (() => void) | undefined;
|
|
137
|
+
const cancelCancelledRowsTimer = (): void => {
|
|
138
|
+
if (cancelledRowsTimer !== undefined) clearTimeout(cancelledRowsTimer);
|
|
139
|
+
cancelledRowsTimer = undefined;
|
|
140
|
+
};
|
|
141
|
+
const scheduleCancelledRowsClear = (): void => {
|
|
142
|
+
cancelCancelledRowsTimer();
|
|
143
|
+
if (!rushPlan) return;
|
|
144
|
+
const delay = cancelledRowsClearDelay(rushPlan);
|
|
145
|
+
if (delay === null) return;
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
if (cancelledRowsTimer !== timer) return;
|
|
148
|
+
cancelledRowsTimer = undefined;
|
|
149
|
+
refresh(true);
|
|
150
|
+
scheduleCancelledRowsClear();
|
|
151
|
+
}, delay);
|
|
152
|
+
cancelledRowsTimer = timer;
|
|
153
|
+
timer.unref?.();
|
|
154
|
+
};
|
|
155
|
+
const observeCancellations = (plan: RushPlan, binding: WorkflowPlanBinding, ctx: ExtensionContext): void => {
|
|
156
|
+
observeCancelledRows(plan, binding, ctx.sessionManager.getBranch(), (type, data) => pi.appendEntry(type, data));
|
|
157
|
+
};
|
|
134
158
|
const unsubscribeWorkflow = pi.events.on("pi-better-workflow:changed", (owner) => {
|
|
135
159
|
displayedWorkflowOwner = owner && typeof owner === "object" && "name" in owner && typeof owner.name === "string"
|
|
136
160
|
? owner.name : null;
|
|
137
161
|
if (displayedWorkflowOwner) {
|
|
162
|
+
cancelCancelledRowsTimer();
|
|
138
163
|
rushBinding = null;
|
|
139
164
|
rushPlan = null;
|
|
140
165
|
rushError = null;
|
|
141
166
|
}
|
|
142
|
-
|
|
167
|
+
refresh(true);
|
|
143
168
|
});
|
|
144
169
|
|
|
145
|
-
const refresh = (force = false): void =>
|
|
170
|
+
const refresh = (force = false): void => {
|
|
171
|
+
refreshWidget?.(force);
|
|
172
|
+
refreshFullPlan?.();
|
|
173
|
+
};
|
|
146
174
|
|
|
147
175
|
const loadRushPlan = (ctx: ExtensionContext): RushPlan | null => {
|
|
148
176
|
const owner = workflowPlanOwner(ctx);
|
|
@@ -152,33 +180,39 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
152
180
|
if (!rushBinding) {
|
|
153
181
|
rushPlan = null;
|
|
154
182
|
rushError = null;
|
|
183
|
+
cancelCancelledRowsTimer();
|
|
155
184
|
return null;
|
|
156
185
|
}
|
|
157
186
|
try {
|
|
158
187
|
const plan = readRushPlan(rushBinding.path, ctx.cwd);
|
|
159
188
|
if (plan.runId !== rushBinding.runId) throw new Error("Rush run identity changed.");
|
|
189
|
+
observeCancellations(plan, rushBinding, ctx);
|
|
160
190
|
rushPlan = plan;
|
|
161
191
|
rushError = null;
|
|
162
192
|
} catch (error) {
|
|
163
193
|
rushPlan = null;
|
|
164
194
|
rushError = error instanceof Error ? error.message : String(error);
|
|
165
195
|
}
|
|
196
|
+
scheduleCancelledRowsClear();
|
|
166
197
|
refresh(true);
|
|
167
198
|
return rushPlan;
|
|
168
199
|
};
|
|
169
200
|
|
|
170
|
-
const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding, readOnly = false): void => {
|
|
201
|
+
const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding, ctx: ExtensionContext, readOnly = false): void => {
|
|
171
202
|
if (!rushBinding || rushBinding.owner !== binding.owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
|
|
172
203
|
pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
|
|
173
204
|
}
|
|
174
205
|
displayedWorkflowOwner = readOnly ? null : binding.owner;
|
|
175
206
|
rushBinding = binding;
|
|
207
|
+
observeCancellations(plan, binding, ctx);
|
|
176
208
|
rushPlan = plan;
|
|
177
209
|
rushError = null;
|
|
210
|
+
scheduleCancelledRowsClear();
|
|
178
211
|
refresh(true);
|
|
179
212
|
};
|
|
180
213
|
|
|
181
214
|
const clearWorkflowPlan = (): void => {
|
|
215
|
+
cancelCancelledRowsTimer();
|
|
182
216
|
if (rushBinding) pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "clear" });
|
|
183
217
|
rushBinding = null;
|
|
184
218
|
rushPlan = null;
|
|
@@ -215,6 +249,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
215
249
|
|
|
216
250
|
const restore = (ctx: ExtensionContext): void => {
|
|
217
251
|
cancelCompletedPlanClear();
|
|
252
|
+
cancelCancelledRowsTimer();
|
|
218
253
|
const state = reconstructPlanState(ctx.sessionManager.getBranch());
|
|
219
254
|
currentPlan = state.plan;
|
|
220
255
|
displayedWorkflowOwner = workflowPlanOwner(ctx);
|
|
@@ -271,7 +306,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
271
306
|
if (!currentPlan) return [];
|
|
272
307
|
return renderCompactPlan(currentPlan, width, theme as never, expanded);
|
|
273
308
|
},
|
|
274
|
-
() => (rushBinding ? rushPlan
|
|
309
|
+
() => (rushBinding && rushPlan ? visibleRushUnits(rushPlan).length : currentPlan?.steps.length ?? 0) > COMPACT_PLAN_ROWS,
|
|
275
310
|
() => expanded,
|
|
276
311
|
() => setExpanded(!expanded),
|
|
277
312
|
);
|
|
@@ -300,16 +335,21 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
300
335
|
ctx.ui.notify(renderRushPlan(plan, 160, true, undefined, planOwner, !owner).join("\n"), "info");
|
|
301
336
|
return;
|
|
302
337
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
component
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
338
|
+
try {
|
|
339
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
340
|
+
refreshFullPlan = () => tui.requestRender();
|
|
341
|
+
const fg = typeof theme?.fg === "function"
|
|
342
|
+
? (color: string, value: string) => theme.fg(color as never, value)
|
|
343
|
+
: undefined;
|
|
344
|
+
const component = createRushPlanComponent(() => rushPlan ?? { ...plan, issues: [] }, () => done(), fg, planOwner, !owner);
|
|
345
|
+
return { render: (width) => component.render(width), handleInput(data) {
|
|
346
|
+
component.handleInput?.(data);
|
|
347
|
+
tui.requestRender();
|
|
348
|
+
}, invalidate: () => component.invalidate() };
|
|
349
|
+
});
|
|
350
|
+
} finally {
|
|
351
|
+
refreshFullPlan = undefined;
|
|
352
|
+
}
|
|
313
353
|
return;
|
|
314
354
|
}
|
|
315
355
|
if (!currentPlan) {
|
|
@@ -384,14 +424,14 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
384
424
|
}
|
|
385
425
|
binding = { owner: owner ?? binding!.owner, path, runId: plan.runId };
|
|
386
426
|
if (transition.event === undefined) {
|
|
387
|
-
displayWorkflowPlan(plan, binding, !owner);
|
|
427
|
+
displayWorkflowPlan(plan, binding, ctx, !owner);
|
|
388
428
|
return { content: [{ type: "text", text: `Showing ${binding.owner} rev ${plan.planRevision}: ${plan.issues.length} units.${owner ? "" : " Read-only handoff."}` }], details: { ok: true, runId: plan.runId, revision: plan.planRevision, ...(!owner ? { readOnly: true } : {}) } };
|
|
389
429
|
}
|
|
390
430
|
}
|
|
391
431
|
if (!binding) throw new Error(`No ${owner} plan is bound; send workflow.path and workflow.revision to update_plan first.`);
|
|
392
432
|
if (transition.event === undefined) throw new Error("workflow.path is required to bind or reload a plan; otherwise send workflow.event and changes.");
|
|
393
433
|
const result = applyRushPlanUpdate(binding.path, ctx.cwd, binding.runId, { ...transition, event: transition.event });
|
|
394
|
-
displayWorkflowPlan(result.plan, binding);
|
|
434
|
+
displayWorkflowPlan(result.plan, binding, ctx);
|
|
395
435
|
const rows = result.changed.map(({ target, id, added }) =>
|
|
396
436
|
`${added ? "+" : ""}${id === null ? "run" : target === "unit" ? `#${id}` : id}`);
|
|
397
437
|
const logNote = result.logAheadRevision === undefined ? ""
|
|
@@ -521,6 +561,8 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
521
561
|
});
|
|
522
562
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
523
563
|
cancelCompletedPlanClear();
|
|
564
|
+
cancelCancelledRowsTimer();
|
|
565
|
+
refreshFullPlan = undefined;
|
|
524
566
|
if (typeof unsubscribeWorkflow === "function") unsubscribeWorkflow();
|
|
525
567
|
refreshWidget = undefined;
|
|
526
568
|
try {
|
|
@@ -12,6 +12,8 @@ export interface PlanRow {
|
|
|
12
12
|
label: string;
|
|
13
13
|
title: string;
|
|
14
14
|
status: DisplayStatus;
|
|
15
|
+
// Workflow cancellations keep skipped styling but are excluded from the completion total.
|
|
16
|
+
cancelled?: boolean;
|
|
15
17
|
statusLabel?: string;
|
|
16
18
|
details?: string[];
|
|
17
19
|
}
|
|
@@ -65,7 +67,8 @@ export function renderPlanPresentation(
|
|
|
65
67
|
): string[] {
|
|
66
68
|
const size = Math.max(1, Math.floor(width));
|
|
67
69
|
const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
|
|
68
|
-
const
|
|
70
|
+
const total = plan.rows.filter((row) => !row.cancelled).length;
|
|
71
|
+
const summary = [`${count("completed")}/${total} complete`];
|
|
69
72
|
for (const [status, label] of [
|
|
70
73
|
["in_progress", "in progress"], ["implemented", "implemented"], ["reviewed", "review passed"],
|
|
71
74
|
["blocked", "blocked"], ["failed", "failed"],
|
|
@@ -129,16 +132,22 @@ export function renderPlanPresentation(
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
export function createPlanPresentationComponent(
|
|
132
|
-
|
|
135
|
+
source: PlanPresentation | (() => PlanPresentation), theme: PlanRenderTheme, onClose: () => void,
|
|
133
136
|
): Component {
|
|
137
|
+
const presentation = () => typeof source === "function" ? source() : source;
|
|
138
|
+
const plan = presentation();
|
|
134
139
|
let selected = plan.rows.findIndex((row) => row.status === "in_progress");
|
|
135
140
|
if (selected < 0) selected = plan.rows.findIndex((row) => row.status !== "completed");
|
|
136
141
|
if (selected < 0) selected = Math.max(0, plan.rows.length - 1);
|
|
137
142
|
return {
|
|
138
|
-
render: (width) =>
|
|
143
|
+
render: (width) => {
|
|
144
|
+
const current = presentation();
|
|
145
|
+
selected = Math.min(selected, Math.max(0, current.rows.length - 1));
|
|
146
|
+
return renderPlanPresentation(current, width, theme, true, selected);
|
|
147
|
+
},
|
|
139
148
|
handleInput(data) {
|
|
140
149
|
if (matchesKey(data, "up")) selected = Math.max(0, selected - 1);
|
|
141
|
-
else if (matchesKey(data, "down")) selected = Math.min(Math.max(0,
|
|
150
|
+
else if (matchesKey(data, "down")) selected = Math.min(Math.max(0, presentation().rows.length - 1), selected + 1);
|
|
142
151
|
else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
|
|
143
152
|
},
|
|
144
153
|
invalidate() {},
|
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
} from "./plan-presentation.js";
|
|
8
8
|
|
|
9
9
|
export const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
|
|
10
|
+
export const CANCELLED_ROW_RETENTION_MS = 30_000;
|
|
10
11
|
|
|
11
12
|
interface RushUnit {
|
|
12
13
|
id: string;
|
|
13
14
|
title: string;
|
|
14
15
|
stage: string;
|
|
15
16
|
status: string;
|
|
17
|
+
cancelledAt?: number;
|
|
16
18
|
dependsOn: string[];
|
|
17
19
|
worker?: string | number | undefined;
|
|
18
20
|
note?: string | undefined;
|
|
@@ -134,7 +136,7 @@ function workflowStatus(status: string): DisplayStatus {
|
|
|
134
136
|
case "pending": return "pending";
|
|
135
137
|
case "blocked": return "blocked";
|
|
136
138
|
case "failed": return "failed";
|
|
137
|
-
case "skipped": case "not-applicable": case "cancelled": return "skipped";
|
|
139
|
+
case "skipped": case "not-applicable": case "cancelled": case "canceled": return "skipped";
|
|
138
140
|
default: return "unknown";
|
|
139
141
|
}
|
|
140
142
|
}
|
|
@@ -162,19 +164,25 @@ function presentRushPlan(plan: RushPlan, owner: string, readOnly = false): PlanP
|
|
|
162
164
|
if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
|
|
163
165
|
return {
|
|
164
166
|
metadata,
|
|
165
|
-
rows: plan.
|
|
167
|
+
rows: visibleRushUnits(plan).map((unit) => {
|
|
166
168
|
const status = unitStatus(unit);
|
|
169
|
+
const cancelled = unit.status === "cancelled" || unit.status === "canceled";
|
|
167
170
|
const details = [`${unit.stage} · ${unit.status}${unit.delivery ? ` · ${unit.delivery}` : ""}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}`];
|
|
168
171
|
if (unit.dependsOn.length) details.push(`after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
|
|
169
172
|
if (unit.note) details.push(unit.note);
|
|
170
173
|
return {
|
|
171
|
-
label: `#${unit.id}`, title: unit.title, status, details,
|
|
172
|
-
...(status === "unknown" ||
|
|
174
|
+
label: `#${unit.id}`, title: unit.title, status, cancelled, details,
|
|
175
|
+
...(status === "unknown" || cancelled ? { statusLabel: unit.status } : {}),
|
|
173
176
|
};
|
|
174
177
|
}),
|
|
175
178
|
};
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
export function visibleRushUnits(plan: RushPlan, now = Date.now()): RushPlan["issues"] {
|
|
182
|
+
return plan.issues.filter(unit => (unit.status !== "cancelled" && unit.status !== "canceled") ||
|
|
183
|
+
unit.cancelledAt === undefined || now < unit.cancelledAt + CANCELLED_ROW_RETENTION_MS);
|
|
184
|
+
}
|
|
185
|
+
|
|
178
186
|
export function renderRushPlan(
|
|
179
187
|
plan: RushPlan,
|
|
180
188
|
width: number,
|
|
@@ -189,12 +197,12 @@ export function renderRushPlan(
|
|
|
189
197
|
}
|
|
190
198
|
|
|
191
199
|
export function createRushPlanComponent(
|
|
192
|
-
|
|
200
|
+
source: RushPlan | (() => RushPlan),
|
|
193
201
|
onClose: () => void,
|
|
194
202
|
fg?: (color: string, value: string) => string,
|
|
195
203
|
owner = "rush-issues",
|
|
196
204
|
readOnly = false,
|
|
197
205
|
): Component {
|
|
198
206
|
const theme = fg ? { fg } : plainPlanTheme;
|
|
199
|
-
return createPlanPresentationComponent(presentRushPlan(
|
|
207
|
+
return createPlanPresentationComponent(() => presentRushPlan(typeof source === "function" ? source() : source, owner, readOnly), theme, onClose);
|
|
200
208
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.2",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"pi-better-background-tasks": "0.9.0",
|
|
57
|
-
"pi-better-goal": "0.
|
|
58
|
-
"pi-better-plan": "0.9.
|
|
57
|
+
"pi-better-goal": "0.9.0",
|
|
58
|
+
"pi-better-plan": "0.9.1",
|
|
59
59
|
"pi-better-sandbox": "0.12.0",
|
|
60
60
|
"pi-better-ssh": "0.1.2",
|
|
61
61
|
"pi-better-subagents": "0.18.0",
|