pi-better-harness 0.23.0 → 0.25.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
@@ -82,7 +82,9 @@ into one disclosure row showing the call count and any failures. Only the failur
82
82
  count uses the error color; the disclosure and total stay muted. Assistant text
83
83
  stays visible, and restored history uses the same folded view. In newer Pi
84
84
  fullscreen mode, click a block disclosure to reveal its call rows, then click a
85
- call to expand its original details. Click the disclosure again to refold the
85
+ call to expand its original details. Hover highlights compact call and disclosure
86
+ rows using the selection background (reverse video for themes without one), and
87
+ tool icons sit centered in a three-column gutter. Click the disclosure again to refold the
86
88
  block; native expanded call/result clicks collapse individual details. Regular
87
89
  terminals keep mouse input for terminal selection and scrollback; Ctrl+O expands
88
90
  all original tool details in both modes and folds them again on the next toggle.
@@ -1,4 +1,4 @@
1
- import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
1
+ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join } from "node:path";
@@ -6,6 +6,11 @@ import { pathToFileURL } from "node:url";
6
6
  import { stripVTControlCharacters } from "node:util";
7
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
8
8
 
9
+ interface ToolUI {
10
+ requestRender(): void;
11
+ children?: TranscriptComponent[];
12
+ handleViewportInput?(data: string): unknown;
13
+ }
9
14
  interface ToolComponent {
10
15
  toolName: string;
11
16
  toolCallId: string;
@@ -16,7 +21,7 @@ interface ToolComponent {
16
21
  result?: { isError?: boolean };
17
22
  showImages: boolean;
18
23
  resultRendererComponent?: unknown;
19
- ui: { requestRender(): void; children?: TranscriptComponent[] };
24
+ ui: ToolUI;
20
25
  invalidate(): void;
21
26
  setExpanded(expanded: boolean): void;
22
27
  render(width: number): string[];
@@ -30,7 +35,7 @@ interface TranscriptComponent extends Component {
30
35
  interface ToolGroup { members: ToolComponent[]; open: boolean; expanded: number }
31
36
 
32
37
  type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
33
- interface ToolMouseEvent { type: string; button: string; y: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
38
+ interface ToolMouseEvent { type: string; button: string; x: number; y: number; screenX: number; screenY: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
34
39
  type MouseHandler = (this: ToolComponent, event: ToolMouseEvent) => unknown;
35
40
  interface ToolPrototype {
36
41
  handleMouse?: MouseHandler;
@@ -49,6 +54,7 @@ interface HookState {
49
54
  restore(): void;
50
55
  completeRun(): void;
51
56
  restoreCompletedCalls(ids: Iterable<string>): void;
57
+ handleTerminalInput(data: string): void;
52
58
  }
53
59
  export interface MinimalOutputHook {
54
60
  setEnabled(enabled: boolean): void;
@@ -90,9 +96,9 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
90
96
  : component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
91
97
  const suffix = failed ? " (failed)" : running ? " (running)" : "";
92
98
  const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
93
- const title = `${icon} ${label}`;
99
+ const title = ` ${icon} ${label}`;
94
100
  const styled = theme
95
- ? `${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
101
+ ? ` ${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
96
102
  : `${title}${detail ? ` ${detail}` : ""}${suffix}`;
97
103
  return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
98
104
  }
@@ -184,6 +190,25 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
184
190
  ...(typeof prototype.handleMouse === "function" ? { handleMouse: prototype.handleMouse } : {}),
185
191
  };
186
192
  let mouseHandling = false;
193
+ const inputWrappers = new Map<ToolUI, { original: NonNullable<ToolUI["handleViewportInput"]>; wrapper: NonNullable<ToolUI["handleViewportInput"]> }>();
194
+ let hovered: { component: ToolComponent; row: number; left: number; top: number; width: number } | undefined;
195
+ const clearHover = () => {
196
+ if (!hovered) return;
197
+ const component = hovered.component;
198
+ hovered = undefined;
199
+ component.ui.requestRender();
200
+ };
201
+ const highlight = (component: ToolComponent, lines: string[], width: number): string[] => {
202
+ if (hovered?.component !== component) return lines;
203
+ const theme = getTheme?.();
204
+ if (width !== hovered.width || hovered.row >= lines.length) { clearHover(); return lines; }
205
+ if (theme) {
206
+ const row = lines[hovered.row] + " ".repeat(Math.max(0, width - visibleWidth(lines[hovered.row])));
207
+ const shaded = theme.bg("selectedBg", row);
208
+ lines[hovered.row] = shaded.startsWith("\x1b[49m") ? theme.inverse(row) : shaded;
209
+ }
210
+ return lines;
211
+ };
187
212
  const seen = new WeakSet<ToolComponent>();
188
213
  const components = new Set<WeakRef<ToolComponent>>();
189
214
  let completed = new Map<string, object>();
@@ -246,6 +271,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
246
271
  this.redraw();
247
272
  },
248
273
  redraw(collapse = false) {
274
+ clearHover();
249
275
  for (const reference of components) {
250
276
  const component = reference.deref();
251
277
  if (!component) { components.delete(reference); continue; }
@@ -255,6 +281,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
255
281
  }
256
282
  },
257
283
  restore() {
284
+ clearHover();
285
+ for (const [ui, { original, wrapper }] of inputWrappers) {
286
+ if (ui.handleViewportInput === wrapper) ui.handleViewportInput = original;
287
+ }
288
+ inputWrappers.clear();
258
289
  for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "render"] as const) {
259
290
  // Do not remove another extension's subsequently installed wrapper.
260
291
  if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
@@ -263,10 +294,30 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
263
294
  delete prototype[HOOK];
264
295
  components.clear();
265
296
  },
297
+ handleTerminalInput(data) {
298
+ if (!hovered) return;
299
+ // Pi routes moves only to the new target; observe input to clear a row on exit.
300
+ const motion = /^\x1b\[<35;(\d+);(\d+)M$/.exec(data);
301
+ if (motion) {
302
+ const x = Number(motion[1]) - 1;
303
+ const y = Number(motion[2]) - 1;
304
+ if (y === hovered.top && x >= hovered.left && x < hovered.left + hovered.width) return;
305
+ }
306
+ clearHover();
307
+ },
266
308
  };
267
309
  const current = state;
268
310
  const wrappers = {
269
311
  updateDisplay(this: ToolComponent, ...args: unknown[]) {
312
+ if (typeof this.ui.handleViewportInput === "function" && !inputWrappers.has(this.ui)) {
313
+ const original = this.ui.handleViewportInput;
314
+ const wrapper = function(this: ToolUI, data: string) {
315
+ current.handleTerminalInput(data);
316
+ return original.call(this, data);
317
+ };
318
+ inputWrappers.set(this.ui, { original, wrapper });
319
+ this.ui.handleViewportInput = wrapper;
320
+ }
270
321
  if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
271
322
  if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
272
323
  const showImages = this.showImages;
@@ -281,6 +332,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
281
332
  }
282
333
  },
283
334
  setExpanded(this: ToolComponent, ...args: unknown[]) {
335
+ clearHover();
284
336
  const group = groups.get(this);
285
337
  const wasExpanded = this.expanded;
286
338
  if (args[0] === false && !mouseHandling && group) group.open = false;
@@ -300,7 +352,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
300
352
  const padding = layout?.anchor?.outputPad ?? 1;
301
353
  const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
302
354
  const group = groupFor(this, layout?.parent);
303
- if (!group) return compactCall(this, width, getTheme?.(), indent);
355
+ if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent), width);
304
356
  const open = group.open || group.expanded > 0;
305
357
  const first = group.members[0] === this;
306
358
  const lines: string[] = [];
@@ -315,11 +367,19 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
315
367
  lines.push(truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width));
316
368
  }
317
369
  if (open) lines.push(...compactCall(this, width, getTheme?.(), indent + 2));
318
- return lines;
370
+ return highlight(this, lines, width);
319
371
  },
320
372
  };
321
373
  const mouseWrapper: MouseHandler = function(event) {
322
374
  if (current.enabled && !this.expanded) {
375
+ if (event.type === "move" && event.button === "none") {
376
+ const lines = this.render(event.width);
377
+ if (event.y < 0 || event.y >= lines.length) { clearHover(); return undefined; }
378
+ if (hovered?.component === this && hovered.row === event.y) return { handled: true, render: false };
379
+ clearHover();
380
+ hovered = { component: this, row: event.y, left: event.screenX - event.x, top: event.screenY, width: event.width };
381
+ return { handled: true, render: true };
382
+ }
323
383
  if (event.type !== "click" || event.button !== "left" || event.shift || event.alt || event.ctrl) return undefined;
324
384
  const group = groupFor(this, layoutFor(this)?.parent);
325
385
  if (group?.members[0] === this && event.y === 0) {
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## What It Does
8
8
 
9
- - Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
9
+ - Gives models one `update_plan` interface for checklist updates, workflow binding, and workflow transitions, plus `get_plan` for inspection.
10
10
  - Supports dependency edges (`id` and `dependsOn`) so independent ready steps can run concurrently.
11
11
  - Shows the complete checklist of completed, active, pending, and blocked steps above the editor.
12
12
  - Persists plan state and display preferences on the active Pi session branch.
@@ -38,7 +38,18 @@ For a DAG, assign stable ids to prerequisite steps and list those ids in depende
38
38
 
39
39
  When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses generic checklist updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
40
40
 
41
- For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), call `sync_workflow_plan` once with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`. This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`; the binding survives Pi session resume, and ownership release restores the prior generic checklist. After that, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
41
+ For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), bind its plan through `update_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`:
42
+
43
+ ```json
44
+ { "workflow": {
45
+ "path": "/absolute/project/.resolve-issues/rush/run-1/task-plan.json",
46
+ "revision": 12
47
+ } }
48
+ ```
49
+
50
+ This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`. It does not rewrite the plan, increment its revision, or append a profiling event. The binding survives Pi session resume, and ownership release restores the prior generic checklist. Repeat this form to reload an externally saved checkpoint at its exact revision. `sync_workflow_plan` has been removed; migrate its old `{path, revision}` arguments into `update_plan`'s `workflow` object.
51
+
52
+ After binding, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
42
53
 
43
54
  ```json
44
55
  { "workflow": {
@@ -52,6 +63,8 @@ For any workflow using the shared task-plan contract (including `resolve-issues`
52
63
  } }
53
64
  ```
54
65
 
66
+ You may include `path` and `revision` alongside `event` and `changes` in the first call to bind and save a transition together. A rejected transition does not activate a new binding. Every successful binding or transition requests a TUI refresh; there is no separate synchronization tool to call afterward.
67
+
55
68
  Each issue row shows where it is between code and delivery: `●` while it is being built, `◐ implemented` once `stage` is `done` with a `headSha`, `◑ review passed` while `reviewedHead` equals `headSha`, and `✓` only at `status: succeeded`, when the change has landed on the target branch by a merged PR or a direct push. Only `✓` rows count as complete.
56
69
 
57
70
  `changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the shared issue-resolution contract (a fleet status of `n/a` is saved as `not-applicable`), refuses a stale `revision`, appends an optional `decision`, then saves the plan atomically with `planRevision + 1` and a new `updatedAt`, and appends one profiling event with the same revision to the log the run already uses (`profiling/run.jsonl` or `profiling.jsonl`). A rejected update changes nothing. The event is appended before the plan is renamed into place, so a crash between the two can leave the log one revision ahead; the next update notices, reuses that revision, and marks its event with `logAheadRevision`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,8 +53,9 @@ const WorkflowRowChangeSchema = Type.Object({
53
53
  });
54
54
 
55
55
  const WorkflowUpdateSchema = Type.Object({
56
- event: Type.String({ description: "Short name for this transition in the profiling log, e.g. unit-validated or component-pr-opened." }),
57
- revision: Type.Optional(Type.Integer({ minimum: 0, description: "The planRevision you last saw. The update is refused if the saved plan has a different revision." })),
56
+ path: Type.Optional(Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json. Send with revision to bind or reload a workflow plan; omit event for a read-only bind." })),
57
+ event: Type.Optional(Type.String({ description: "Short name for a saved transition, e.g. unit-validated. Required when sending changes, decision, or profiling; omit for a read-only bind." })),
58
+ revision: Type.Optional(Type.Integer({ minimum: 0, description: "The planRevision you last saw. Required with path. The update is refused if the saved plan has a different revision." })),
58
59
  changes: Type.Optional(Type.Array(WorkflowRowChangeSchema, { maxItems: 50, description: "Every row this transition changes. All of them are saved together as one revision." })),
59
60
  decision: Type.Optional(Type.Object({
60
61
  id: Type.String({ description: "New, unique decision id." }),
@@ -66,7 +67,7 @@ const WorkflowUpdateSchema = Type.Object({
66
67
  additionalProperties: true,
67
68
  description: "Extra fields for this transition's profiling event, e.g. outcome, wallMs, waitMs, headSha.",
68
69
  })),
69
- }, { description: "Workflow task-plan transition. Use only while a workflow owns the plan and it is bound with sync_workflow_plan; send this instead of plan." });
70
+ }, { description: "Bind, reload, or update a workflow-owned task plan. Send this instead of plan while a workflow owns planning." });
70
71
 
71
72
  const UpdatePlanSchema = Type.Object({
72
73
  explanation: Type.Optional(Type.String({ description: "Why the plan or its status changed." })),
@@ -74,6 +75,8 @@ const UpdatePlanSchema = Type.Object({
74
75
  workflow: Type.Optional(WorkflowUpdateSchema),
75
76
  });
76
77
 
78
+ type WorkflowPlanInput = Omit<RushPlanUpdate, "event"> & { path?: string; event?: string };
79
+
77
80
  const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
78
81
  const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
79
82
  type DelegationMode = "manual" | "adaptive" | "coordinator";
@@ -148,6 +151,17 @@ export default function planExtension(pi: ExtensionAPI): void {
148
151
  return rushPlan;
149
152
  };
150
153
 
154
+ const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding): void => {
155
+ if (!rushBinding || rushBinding.owner !== binding.owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
156
+ pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
157
+ }
158
+ displayedWorkflowOwner = binding.owner;
159
+ rushBinding = binding;
160
+ rushPlan = plan;
161
+ rushError = null;
162
+ refresh(true);
163
+ };
164
+
151
165
  const cancelCompletedPlanClear = (): void => {
152
166
  if (completedPlanClearTimer) clearTimeout(completedPlanClearTimer);
153
167
  completedPlanClearTimer = undefined;
@@ -282,41 +296,10 @@ export default function planExtension(pi: ExtensionAPI): void {
282
296
  };
283
297
 
284
298
 
285
- pi.registerTool({
286
- name: "sync_workflow_plan",
287
- label: "Sync Workflow Plan",
288
- description: "Bind and display an active workflow's compatible persisted task plan at its exact checkpoint revision. Record later transitions with update_plan's workflow field.",
289
- parameters: Type.Object({
290
- path: Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json" }),
291
- revision: Type.Integer({ minimum: 0, description: "Persisted planRevision to display" }),
292
- }),
293
- async execute(_id, params, _signal, _onUpdate, ctx) {
294
- const owner = workflowPlanOwner(ctx);
295
- if (!owner) throw new Error("Only an active workflow can sync its plan.");
296
- const plan = readRushPlan(params.path, ctx.cwd);
297
- if (plan.planRevision !== params.revision) {
298
- rushPlan = null;
299
- rushError = `revision mismatch: expected ${params.revision}, found ${plan.planRevision}`;
300
- refresh(true);
301
- throw new Error(`Rush plan ${rushError}.`);
302
- }
303
- const binding: WorkflowPlanBinding = { owner, path: params.path, runId: plan.runId };
304
- if (!rushBinding || rushBinding.owner !== owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
305
- pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
306
- }
307
- displayedWorkflowOwner = owner;
308
- rushBinding = binding;
309
- rushPlan = plan;
310
- rushError = null;
311
- refresh(true);
312
- return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { runId: plan.runId, revision: plan.planRevision } };
313
- },
314
- });
315
-
316
299
  pi.registerTool({
317
300
  name: "update_plan",
318
301
  label: "Update Plan",
319
- description: "Create or atomically replace the current structured execution plan and its step statuses. While a workflow owns a bound plan, send workflow instead of plan to save one task-plan transition.",
302
+ description: "Universal plan interface: create or replace a generic checklist, bind or reload a workflow plan with workflow.path and revision, or save a workflow transition with event and changes. Every successful call refreshes the displayed plan.",
320
303
  promptSnippet: "Create and update a persistent structured execution plan",
321
304
  promptGuidelines: [
322
305
  "Use update_plan for work with three or more meaningful steps unless a skill owns planning; update it immediately when a step completes, becomes blocked, or scope changes.",
@@ -325,26 +308,47 @@ export default function planExtension(pi: ExtensionAPI): void {
325
308
  "For generic plans, follow the active delegation mode. Manual forbids proactive delegation even in plan mode; adaptive favors substantial independent work; coordinator delegates nontrivial role-owned tasks after agents_catalog discovery.",
326
309
  "Use a generic plan as a milestone ledger only when no workflow owns planning. Otherwise follow the workflow's task plan and foreground role.",
327
310
  "For generic plans, use separate steps for distinct deliverables, not one step per worker process. Before completing verification or the plan, inspect and integrate every relevant delegated result or failure.",
328
- "While a workflow owns the plan and it is bound with sync_workflow_plan, record every transition with update_plan's workflow field instead of editing task-plan.json or its profiling log; it saves the rows, the next planRevision, and the matching profiling event together.",
311
+ "While a workflow owns planning, bind its persisted task plan with update_plan({workflow:{path,revision}}). Then record every transition with update_plan's workflow event and changes instead of editing task-plan.json or its profiling log; it saves the rows, the next planRevision, and the matching profiling event together. Path and revision can accompany the first transition to bind and update in one call.",
329
312
  ],
330
313
  parameters: UpdatePlanSchema,
331
314
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
332
315
  const owner = workflowPlanOwner(ctx);
333
- const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: RushPlanUpdate };
316
+ const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: WorkflowPlanInput };
334
317
  if (input.workflow !== undefined) {
335
318
  if (!owner) throw new Error("No workflow owns the task plan; send plan instead of workflow.");
336
319
  if (input.plan !== undefined) throw new Error("Send either plan or workflow, not both.");
337
- if (!rushBinding || rushBinding.owner !== owner) throw new Error(`No ${owner} plan is bound; call sync_workflow_plan with the task-plan.json path first.`);
338
- const result = applyRushPlanUpdate(rushBinding.path, ctx.cwd, rushBinding.runId, input.workflow);
339
- rushPlan = result.plan;
340
- rushError = null;
341
- refresh(true);
320
+ const { path, ...transition } = input.workflow;
321
+ if (transition.event === undefined && (transition.changes !== undefined || transition.decision !== undefined || transition.profiling !== undefined)) {
322
+ throw new Error("workflow.event is required when sending changes, decision, or profiling.");
323
+ }
324
+ let binding = rushBinding?.owner === owner ? rushBinding : null;
325
+ if (path !== undefined) {
326
+ if (transition.revision === undefined) throw new Error("workflow.revision is required with workflow.path.");
327
+ const plan = readRushPlan(path, ctx.cwd);
328
+ if (plan.planRevision !== transition.revision) {
329
+ if (transition.event === undefined) {
330
+ rushPlan = null;
331
+ rushError = `revision mismatch: expected ${transition.revision}, found ${plan.planRevision}`;
332
+ refresh(true);
333
+ }
334
+ throw new Error(`Rush plan revision mismatch: expected ${transition.revision}, found ${plan.planRevision}.`);
335
+ }
336
+ binding = { owner, path, runId: plan.runId };
337
+ if (transition.event === undefined) {
338
+ displayWorkflowPlan(plan, binding);
339
+ return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { ok: true, runId: plan.runId, revision: plan.planRevision } };
340
+ }
341
+ }
342
+ if (!binding) throw new Error(`No ${owner} plan is bound; send workflow.path and workflow.revision to update_plan first.`);
343
+ if (transition.event === undefined) throw new Error("workflow.path is required to bind or reload a plan; otherwise send workflow.event and changes.");
344
+ const result = applyRushPlanUpdate(binding.path, ctx.cwd, binding.runId, { ...transition, event: transition.event });
345
+ displayWorkflowPlan(result.plan, binding);
342
346
  const rows = result.changed.map(({ target, id, added }) =>
343
347
  `${added ? "+" : ""}${id === null ? "run" : target === "unit" ? `#${id}` : id}`);
344
348
  const logNote = result.logAheadRevision === undefined ? ""
345
349
  : ` The profiling log already held rev ${result.logAheadRevision} (an earlier write stopped after logging); this event supersedes it.`;
346
350
  return {
347
- content: [{ type: "text", text: `Saved ${owner} rev ${result.revision} (${input.workflow.event.trim()})${rows.length ? `: ${rows.join(", ")}` : ""}${input.workflow.decision ? `${rows.length ? ";" : ":"} decision ${input.workflow.decision.id}` : ""}. Profiling: ${result.profilingPath}.${logNote}` }],
351
+ content: [{ type: "text", text: `Saved ${owner} rev ${result.revision} (${transition.event.trim()})${rows.length ? `: ${rows.join(", ")}` : ""}${input.workflow.decision ? `${rows.length ? ";" : ":"} decision ${input.workflow.decision.id}` : ""}. Profiling: ${result.profilingPath}.${logNote}` }],
348
352
  details: {
349
353
  ok: true, runId: result.plan.runId, revision: result.revision, changed: result.changed, profilingPath: result.profilingPath,
350
354
  ...(result.logAheadRevision === undefined ? {} : { logAheadRevision: result.logAheadRevision }),
@@ -352,7 +356,7 @@ export default function planExtension(pi: ExtensionAPI): void {
352
356
  };
353
357
  }
354
358
  if (owner) {
355
- throw new Error(`${owner} owns the task plan. Send workflow (bound with sync_workflow_plan) instead of plan.`);
359
+ throw new Error(`${owner} owns the task plan. Send workflow instead of plan; include path and revision to bind its persisted task plan.`);
356
360
  }
357
361
  if (input.plan === undefined) throw new Error("plan is required: send the full list of steps.");
358
362
  const plan = replacePlan(currentPlan, input.plan, input.explanation);
@@ -366,7 +370,7 @@ export default function planExtension(pi: ExtensionAPI): void {
366
370
  renderCall(args, theme) {
367
371
  return new Text(
368
372
  theme.fg("toolTitle", theme.bold("update_plan ")) + theme.fg("muted", args.workflow
369
- ? `workflow ${args.workflow.event ?? ""}`.trimEnd()
373
+ ? `workflow ${args.workflow.event ?? "bind"}`
370
374
  : `${args.plan?.length ?? 0} steps`),
371
375
  0,
372
376
  0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
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",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "pi-better-background-tasks": "0.7.0",
57
57
  "pi-better-goal": "0.8.1",
58
- "pi-better-plan": "0.6.0",
58
+ "pi-better-plan": "0.7.0",
59
59
  "pi-better-sandbox": "0.11.0",
60
60
  "pi-better-ssh": "0.1.2",
61
61
  "pi-better-subagents": "0.15.0",