ultimate-pi 0.10.0 → 0.10.1

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.
@@ -329,6 +329,52 @@ export default function harnessLiveWidget(pi: ExtensionAPI) {
329
329
  let component: HarnessWidgetComponent | null = null;
330
330
  let refreshQueued = false;
331
331
  let lastRenderHash = "";
332
+ let mountCtx: ExtensionContext | null = null;
333
+
334
+ function mountHarnessWidget(ctx: ExtensionContext): void {
335
+ if (!ctx.hasUI) return;
336
+ const state = stateStore.refresh(ctx);
337
+ const inFlight: InFlightState = { toolCount: 0, lastToolName: null };
338
+ lastRenderHash = computeRenderHash(state, inFlight);
339
+
340
+ ctx.ui.setWidget(
341
+ "harness-live",
342
+ (tui, theme) => {
343
+ widgetMounted = true;
344
+ tuiHandle = tui;
345
+ component = new HarnessWidgetComponent(
346
+ stateStore.snapshot(),
347
+ inFlight,
348
+ theme,
349
+ );
350
+ return {
351
+ render(width: number): string[] {
352
+ component?.setTheme(theme);
353
+ return component?.render(width) ?? [];
354
+ },
355
+ invalidate(): void {
356
+ component?.invalidate();
357
+ },
358
+ };
359
+ },
360
+ { placement: "aboveEditor" },
361
+ );
362
+ updateStatusFallback(ctx, state);
363
+ }
364
+
365
+ function remountHarnessLiveWidget(ctx: ExtensionContext): void {
366
+ if (!ctx.hasUI || !widgetMounted) return;
367
+ ctx.ui.setWidget("harness-live", undefined);
368
+ mountHarnessWidget(ctx);
369
+ }
370
+
371
+ pi.events.on("subagents:agents-widget-mounted", () => {
372
+ if (mountCtx) remountHarnessLiveWidget(mountCtx);
373
+ });
374
+
375
+ pi.events.on("plan-approval:mounted", () => {
376
+ if (mountCtx) remountHarnessLiveWidget(mountCtx);
377
+ });
332
378
 
333
379
  function updateStatusFallback(
334
380
  ctx: ExtensionContext,
@@ -385,34 +431,8 @@ export default function harnessLiveWidget(pi: ExtensionAPI) {
385
431
  }
386
432
 
387
433
  pi.on("session_start", (_event, ctx) => {
388
- if (!ctx.hasUI) return;
389
- const state = stateStore.refresh(ctx);
390
- const inFlight: InFlightState = { toolCount: 0, lastToolName: null };
391
- lastRenderHash = computeRenderHash(state, inFlight);
392
-
393
- ctx.ui.setWidget(
394
- "harness-live",
395
- (tui, theme) => {
396
- widgetMounted = true;
397
- tuiHandle = tui;
398
- component = new HarnessWidgetComponent(
399
- stateStore.snapshot(),
400
- inFlight,
401
- theme,
402
- );
403
- return {
404
- render(width: number): string[] {
405
- component?.setTheme(theme);
406
- return component?.render(width) ?? [];
407
- },
408
- invalidate(): void {
409
- component?.invalidate();
410
- },
411
- };
412
- },
413
- { placement: "aboveEditor" },
414
- );
415
- updateStatusFallback(ctx, state);
434
+ mountCtx = ctx;
435
+ mountHarnessWidget(ctx);
416
436
  });
417
437
 
418
438
  pi.on("context", (_event, ctx) => {
@@ -7,6 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import {
8
8
  appendPlanApprovalIfNew,
9
9
  getLatestRunContext,
10
+ hasPlanUserApproval,
10
11
  parsePlanApprovalFromMessage,
11
12
  } from "../lib/harness-run-context.js";
12
13
  import { runPlanApprovalDialog } from "./lib/plan-approval/dialog.js";
@@ -32,26 +33,29 @@ import {
32
33
  } from "./lib/plan-approval/validate.js";
33
34
 
34
35
  export default function harnessPlanApproval(pi: ExtensionAPI) {
35
- pi.registerMessageRenderer("harness-plan-draft", (message, _options, theme) => {
36
- const data = message.details as
37
- | {
38
- plan_packet?: unknown;
39
- human_summary?: string | null;
40
- }
41
- | undefined;
42
- if (!data?.plan_packet) return undefined;
43
- const lines = renderHarnessPlanDraft(
44
- {
45
- plan_packet: data.plan_packet as Parameters<
46
- typeof renderHarnessPlanDraft
47
- >[0]["plan_packet"],
48
- human_summary: data.human_summary,
49
- },
50
- 80,
51
- theme,
52
- );
53
- return new Text(lines.join("\n"), 0, 0);
54
- });
36
+ pi.registerMessageRenderer(
37
+ "harness-plan-draft",
38
+ (message, _options, theme) => {
39
+ const data = message.details as
40
+ | {
41
+ plan_packet?: unknown;
42
+ human_summary?: string | null;
43
+ }
44
+ | undefined;
45
+ if (!data?.plan_packet) return undefined;
46
+ const lines = renderHarnessPlanDraft(
47
+ {
48
+ plan_packet: data.plan_packet as Parameters<
49
+ typeof renderHarnessPlanDraft
50
+ >[0]["plan_packet"],
51
+ human_summary: data.human_summary,
52
+ },
53
+ 80,
54
+ theme,
55
+ );
56
+ return new Text(lines.join("\n"), 0, 0);
57
+ },
58
+ );
55
59
 
56
60
  pi.registerTool({
57
61
  name: "approve_plan",
@@ -76,6 +80,33 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
76
80
  };
77
81
  }
78
82
 
83
+ const entries = ctx.sessionManager.getEntries();
84
+ if (
85
+ hasPlanUserApproval(entries, {
86
+ sincePlanCommand: true,
87
+ planId: validated.plan_packet.plan_id ?? null,
88
+ })
89
+ ) {
90
+ const planId = String(validated.plan_packet.plan_id ?? "plan");
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: `Plan ${planId} already approved in this harness run (planner subagent). Proceed with /harness-run.`,
96
+ },
97
+ ],
98
+ details: {
99
+ plan_packet: validated.plan_packet,
100
+ options: validated.options,
101
+ response: {
102
+ kind: "selection",
103
+ selections: ["Approve"],
104
+ },
105
+ cancelled: false,
106
+ },
107
+ };
108
+ }
109
+
79
110
  const planId = String(validated.plan_packet.plan_id ?? "plan");
80
111
  const summary =
81
112
  validated.human_summary?.trim() ||
@@ -94,7 +125,11 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
94
125
 
95
126
  let outcome: PlanApprovalDialogResult;
96
127
  if (ctx.hasUI) {
97
- outcome = await runPlanApprovalDialog(ctx.ui, validated);
128
+ outcome = await runPlanApprovalDialog(ctx.ui, validated, {
129
+ onMounted: () => {
130
+ pi.events.emit("plan-approval:mounted", {});
131
+ },
132
+ });
98
133
  } else {
99
134
  outcome = await runPlanApprovalFallback(ctx.ui, validated);
100
135
  }
@@ -109,7 +144,6 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
109
144
  details,
110
145
  });
111
146
  if (approval) {
112
- const entries = ctx.sessionManager.getEntries();
113
147
  const runCtx = getLatestRunContext(entries);
114
148
  appendPlanApprovalIfNew(
115
149
  (type, data) => pi.appendEntry(type, data),
@@ -671,20 +671,29 @@ export default function harnessRunContext(pi: ExtensionAPI) {
671
671
  });
672
672
 
673
673
  pi.on("tool_call", async (event, ctx) => {
674
- if (event.toolName === "ask_user" && activeCtx?.plan_packet_path) {
675
- const input = event.input as {
676
- question?: string;
677
- options?: unknown[];
678
- };
679
- if (
680
- isPlanApprovalAskUser(input) &&
681
- hasPlanUserApproval(getEntries(ctx), { sincePlanCommand: true })
682
- ) {
683
- return {
684
- block: true,
685
- reason:
686
- "harness-run-context: plan already approved via planner subagent; do not call ask_user for plan approval in the parent session.",
687
- };
674
+ if (activeCtx?.plan_packet_path) {
675
+ const entries = getEntries(ctx);
676
+ if (hasPlanUserApproval(entries, { sincePlanCommand: true })) {
677
+ if (event.toolName === "approve_plan") {
678
+ return {
679
+ block: true,
680
+ reason:
681
+ "harness-run-context: plan already approved via planner subagent; do not call approve_plan again in the parent session.",
682
+ };
683
+ }
684
+ if (event.toolName === "ask_user") {
685
+ const input = event.input as {
686
+ question?: string;
687
+ options?: unknown[];
688
+ };
689
+ if (isPlanApprovalAskUser(input)) {
690
+ return {
691
+ block: true,
692
+ reason:
693
+ "harness-run-context: plan already approved via planner subagent; do not call ask_user for plan approval in the parent session.",
694
+ };
695
+ }
696
+ }
688
697
  }
689
698
  }
690
699
  if (!activeCtx?.plan_packet_path) return undefined;
@@ -198,7 +198,11 @@ export function createParentHarnessUiBridgeFactory(
198
198
 
199
199
  let outcome: DialogResult;
200
200
  if (parentCtx.hasUI) {
201
- outcome = await runPlanApprovalDialog(parentCtx.ui, validated);
201
+ outcome = await runPlanApprovalDialog(parentCtx.ui, validated, {
202
+ onMounted: () => {
203
+ pi.events.emit("plan-approval:mounted", {});
204
+ },
205
+ });
202
206
  } else {
203
207
  outcome = await runPlanApprovalFallback(parentCtx.ui, validated);
204
208
  }
@@ -759,7 +759,9 @@ export function createHarnessSubagentsExtension(packageRoot: string) {
759
759
  });
760
760
 
761
761
  // Live widget: show running agents above editor
762
- const widget = new AgentWidget(manager, agentActivity);
762
+ const widget = new AgentWidget(manager, agentActivity, () => {
763
+ pi.events.emit("subagents:agents-widget-mounted", {});
764
+ });
763
765
 
764
766
  // ---- Join mode configuration ----
765
767
  let defaultJoinMode: JoinMode = "smart";
@@ -264,6 +264,7 @@ export class AgentWidget {
264
264
  constructor(
265
265
  private manager: AgentManager,
266
266
  private agentActivity: Map<string, AgentActivity>,
267
+ private onWidgetRegistered?: () => void,
267
268
  ) {}
268
269
 
269
270
  /** Set the UI context (grabbed from first tool execution). */
@@ -615,6 +616,7 @@ export class AgentWidget {
615
616
  { placement: "aboveEditor" },
616
617
  );
617
618
  this.widgetRegistered = true;
619
+ this.onWidgetRegistered?.();
618
620
  } else {
619
621
  // Widget already registered — just request a re-render of existing components.
620
622
  this.tui?.requestRender();
@@ -12,6 +12,51 @@ interface CustomAnswer {
12
12
  response: { kind: "selection"; selections: string[] };
13
13
  }
14
14
 
15
+ /** Lines reserved below overlay: harness-live widget + editor + footer. */
16
+ export const PLAN_APPROVAL_BOTTOM_RESERVE_LINES = 11;
17
+ /** Estimate agents widget height when stacking above harness live. */
18
+ export const PLAN_APPROVAL_AGENTS_TOP_RESERVE_LINES = 12;
19
+ export const PLAN_APPROVAL_MIN_VIEWPORT = 6;
20
+
21
+ export function computePlanViewport(
22
+ availableHeight: number,
23
+ chromeLines: number,
24
+ ): number {
25
+ return Math.max(PLAN_APPROVAL_MIN_VIEWPORT, availableHeight - chromeLines);
26
+ }
27
+
28
+ export function computePlanOverlayMaxHeight(termHeight: number): number {
29
+ return Math.max(
30
+ PLAN_APPROVAL_MIN_VIEWPORT + 8,
31
+ termHeight -
32
+ PLAN_APPROVAL_BOTTOM_RESERVE_LINES -
33
+ PLAN_APPROVAL_AGENTS_TOP_RESERVE_LINES,
34
+ );
35
+ }
36
+
37
+ function countPlanChromeLines(
38
+ validated: ValidatedApprovePlanParams,
39
+ displayOptions: ValidatedApprovePlanParams["options"],
40
+ useOverlay: boolean,
41
+ ): number {
42
+ let chrome = useOverlay ? 2 : 0; // borders
43
+ chrome += 1; // title
44
+ if (validated.human_summary) {
45
+ chrome += validated.human_summary.split("\n").length;
46
+ }
47
+ chrome += 1; // blank before plan
48
+ chrome += 1; // plan label
49
+ chrome += 1; // blank before options
50
+ chrome += 1; // options label
51
+ for (const opt of displayOptions) {
52
+ chrome += 1;
53
+ if (opt.description) chrome += 1;
54
+ }
55
+ chrome += 1; // blank before hints
56
+ chrome += 1; // hints
57
+ return chrome;
58
+ }
59
+
15
60
  function withTimeout<T>(
16
61
  promise: Promise<T | null>,
17
62
  ms: number | undefined,
@@ -25,177 +70,216 @@ function withTimeout<T>(
25
70
  ]);
26
71
  }
27
72
 
73
+ export type RunPlanApprovalDialogOptions = {
74
+ onMounted?: () => void;
75
+ };
76
+
28
77
  export async function runPlanApprovalDialog(
29
78
  ui: ExtensionUIContext,
30
79
  validated: ValidatedApprovePlanParams,
80
+ options?: RunPlanApprovalDialogOptions,
31
81
  ): Promise<PlanApprovalDialogResult> {
32
82
  const planLines = formatPlanPacketLines(validated.plan_packet, 100);
33
83
  const displayOptions = validated.options;
84
+ const useOverlay = validated.displayMode !== "inline";
85
+ let overlayTermHeight = 24;
34
86
 
35
87
  const result = await withTimeout(
36
- ui.custom<CustomAnswer | null>((tui, theme, _kb, done) => {
37
- let scrollOffset = 0;
38
- let optionIndex = 0;
39
- let focus: FocusRegion = "plan";
40
- let cachedLines: string[] | undefined;
41
-
42
- function refresh() {
43
- cachedLines = undefined;
44
- tui.requestRender();
45
- }
46
-
47
- function submitSelection() {
48
- const opt = displayOptions[optionIndex];
49
- done({
50
- response: { kind: "selection", selections: [opt.title] },
51
- });
52
- }
53
-
54
- function handleInput(data: string) {
55
- if (focus === "plan") {
56
- if (matchesKey(data, Key.up) || data === "k") {
57
- scrollOffset = Math.max(0, scrollOffset - 1);
58
- refresh();
59
- return;
60
- }
61
- if (matchesKey(data, Key.down) || data === "j") {
62
- scrollOffset += 1;
63
- refresh();
64
- return;
65
- }
66
- if (matchesKey(data, Key.pageUp)) {
67
- scrollOffset = Math.max(0, scrollOffset - 8);
68
- refresh();
69
- return;
70
- }
71
- if (matchesKey(data, Key.pageDown)) {
72
- scrollOffset += 8;
73
- refresh();
74
- return;
75
- }
76
- if (matchesKey(data, Key.tab)) {
77
- focus = "options";
78
- refresh();
79
- return;
80
- }
88
+ ui.custom<CustomAnswer | null>(
89
+ (tui, theme, _kb, done) => {
90
+ const tuiHeight = (tui as unknown as { height?: number }).height;
91
+ overlayTermHeight =
92
+ typeof tuiHeight === "number" && tuiHeight > 10 ? tuiHeight : 24;
93
+ options?.onMounted?.();
94
+
95
+ let scrollOffset = 0;
96
+ let optionIndex = 0;
97
+ let focus: FocusRegion = "plan";
98
+ let cachedLines: string[] | undefined;
99
+
100
+ function refresh() {
101
+ cachedLines = undefined;
102
+ tui.requestRender();
81
103
  }
82
104
 
83
- if (focus === "options") {
84
- if (matchesKey(data, Key.up)) {
85
- optionIndex = Math.max(0, optionIndex - 1);
86
- refresh();
87
- return;
88
- }
89
- if (matchesKey(data, Key.down)) {
90
- optionIndex = Math.min(displayOptions.length - 1, optionIndex + 1);
91
- refresh();
92
- return;
105
+ function submitSelection() {
106
+ const opt = displayOptions[optionIndex];
107
+ done({
108
+ response: { kind: "selection", selections: [opt.title] },
109
+ });
110
+ }
111
+
112
+ function handleInput(data: string) {
113
+ if (focus === "plan") {
114
+ if (matchesKey(data, Key.up) || data === "k") {
115
+ scrollOffset = Math.max(0, scrollOffset - 1);
116
+ refresh();
117
+ return;
118
+ }
119
+ if (matchesKey(data, Key.down) || data === "j") {
120
+ scrollOffset += 1;
121
+ refresh();
122
+ return;
123
+ }
124
+ if (matchesKey(data, Key.pageUp)) {
125
+ scrollOffset = Math.max(0, scrollOffset - 8);
126
+ refresh();
127
+ return;
128
+ }
129
+ if (matchesKey(data, Key.pageDown)) {
130
+ scrollOffset += 8;
131
+ refresh();
132
+ return;
133
+ }
134
+ if (matchesKey(data, Key.tab)) {
135
+ focus = "options";
136
+ refresh();
137
+ return;
138
+ }
93
139
  }
94
- if (matchesKey(data, Key.tab)) {
95
- focus = "plan";
96
- refresh();
97
- return;
140
+
141
+ if (focus === "options") {
142
+ if (matchesKey(data, Key.up)) {
143
+ optionIndex = Math.max(0, optionIndex - 1);
144
+ refresh();
145
+ return;
146
+ }
147
+ if (matchesKey(data, Key.down)) {
148
+ optionIndex = Math.min(
149
+ displayOptions.length - 1,
150
+ optionIndex + 1,
151
+ );
152
+ refresh();
153
+ return;
154
+ }
155
+ if (matchesKey(data, Key.tab)) {
156
+ focus = "plan";
157
+ refresh();
158
+ return;
159
+ }
160
+ if (matchesKey(data, Key.enter)) {
161
+ submitSelection();
162
+ return;
163
+ }
98
164
  }
99
- if (matchesKey(data, Key.enter)) {
100
- submitSelection();
101
- return;
165
+
166
+ if (matchesKey(data, Key.escape)) {
167
+ done(null);
102
168
  }
103
169
  }
104
170
 
105
- if (matchesKey(data, Key.escape)) {
106
- done(null);
107
- }
108
- }
109
-
110
- function render(width: number): string[] {
111
- if (cachedLines) return cachedLines;
112
-
113
- const lines: string[] = [];
114
- const add = (s: string) => lines.push(truncateToWidth(s, width));
115
- const useOverlay = validated.displayMode !== "inline";
116
- const dims = (tui as { height?: number }).height;
117
- const termHeight = typeof dims === "number" && dims > 10 ? dims : 24;
118
- const footerLines = displayOptions.length * 2 + 8;
119
- const planViewport = Math.max(
120
- 6,
121
- Math.floor(termHeight * 0.55) - footerLines,
122
- );
123
-
124
- if (useOverlay) {
125
- add(theme.fg("accent", "─".repeat(width)));
126
- }
171
+ function render(width: number): string[] {
172
+ if (cachedLines) return cachedLines;
127
173
 
128
- add(theme.fg("accent", " Plan approval"));
129
- if (validated.human_summary) {
130
- for (const line of validated.human_summary.split("\n")) {
131
- add(theme.fg("muted", ` ${line}`));
132
- }
133
- }
134
- lines.push("");
135
-
136
- const maxScroll = Math.max(0, planLines.length - planViewport);
137
- scrollOffset = Math.min(scrollOffset, maxScroll);
138
- const visible = planLines.slice(
139
- scrollOffset,
140
- scrollOffset + planViewport,
141
- );
142
- const planLabel =
143
- focus === "plan"
144
- ? theme.fg("accent", " [plan — ↑↓/Pg scroll, Tab → options]")
145
- : theme.fg("dim", " [plan]");
146
- add(planLabel);
147
- for (const line of visible) {
148
- add(theme.fg("text", ` ${line}`));
149
- }
150
- if (planLines.length > planViewport) {
151
- add(
152
- theme.fg(
153
- "dim",
154
- ` … ${scrollOffset + 1}-${scrollOffset + visible.length} of ${planLines.length}`,
155
- ),
174
+ const lines: string[] = [];
175
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
176
+ const dims = (tui as { height?: number }).height;
177
+ const termHeight = typeof dims === "number" && dims > 10 ? dims : 24;
178
+ const chromeLines = countPlanChromeLines(
179
+ validated,
180
+ displayOptions,
181
+ useOverlay,
156
182
  );
157
- }
158
- lines.push("");
159
-
160
- const optLabel =
161
- focus === "options"
162
- ? theme.fg("accent", " Options (↑↓, Enter, Tab → plan):")
163
- : theme.fg("dim", " Options (Tab to focus):");
164
- add(optLabel);
165
- for (let i = 0; i < displayOptions.length; i++) {
166
- const opt = displayOptions[i];
167
- const focused = focus === "options" && i === optionIndex;
168
- const prefix = focused ? theme.fg("accent", "> ") : " ";
169
- const num = `${i + 1}. `;
170
- if (focused) {
171
- add(prefix + theme.fg("accent", `${num}${opt.title}`));
183
+
184
+ let availableHeight: number;
185
+ if (useOverlay) {
186
+ const overlayMax = computePlanOverlayMaxHeight(termHeight);
187
+ availableHeight = overlayMax;
172
188
  } else {
173
- add(`${prefix}${theme.fg("text", `${num}${opt.title}`)}`);
189
+ availableHeight = termHeight - PLAN_APPROVAL_BOTTOM_RESERVE_LINES;
174
190
  }
175
- if (opt.description) {
176
- add(` ${theme.fg("muted", opt.description)}`);
191
+ const planViewport = computePlanViewport(
192
+ availableHeight,
193
+ chromeLines,
194
+ );
195
+
196
+ if (useOverlay) {
197
+ add(theme.fg("accent", "─".repeat(width)));
177
198
  }
178
- }
179
199
 
180
- lines.push("");
181
- add(theme.fg("dim", " Tab: plan ↔ options • Esc: cancel"));
200
+ add(theme.fg("accent", " Plan approval"));
201
+ if (validated.human_summary) {
202
+ for (const line of validated.human_summary.split("\n")) {
203
+ add(theme.fg("muted", ` ${line}`));
204
+ }
205
+ }
206
+ lines.push("");
182
207
 
183
- if (useOverlay) {
184
- add(theme.fg("accent", "─".repeat(width)));
185
- }
208
+ const maxScroll = Math.max(0, planLines.length - planViewport);
209
+ scrollOffset = Math.min(scrollOffset, maxScroll);
210
+ const visible = planLines.slice(
211
+ scrollOffset,
212
+ scrollOffset + planViewport,
213
+ );
214
+ const planLabel =
215
+ focus === "plan"
216
+ ? theme.fg("accent", " [plan — ↑↓/Pg scroll, Tab → options]")
217
+ : theme.fg("dim", " [plan]");
218
+ add(planLabel);
219
+ for (const line of visible) {
220
+ add(theme.fg("text", ` ${line}`));
221
+ }
222
+ if (planLines.length > planViewport) {
223
+ add(
224
+ theme.fg(
225
+ "dim",
226
+ ` … ${scrollOffset + 1}-${scrollOffset + visible.length} of ${planLines.length}`,
227
+ ),
228
+ );
229
+ }
230
+ lines.push("");
186
231
 
187
- cachedLines = lines;
188
- return lines;
189
- }
232
+ const optLabel =
233
+ focus === "options"
234
+ ? theme.fg("accent", " Options (↑↓, Enter, Tab → plan):")
235
+ : theme.fg("dim", " Options (Tab to focus):");
236
+ add(optLabel);
237
+ for (let i = 0; i < displayOptions.length; i++) {
238
+ const opt = displayOptions[i];
239
+ const focused = focus === "options" && i === optionIndex;
240
+ const prefix = focused ? theme.fg("accent", "> ") : " ";
241
+ const num = `${i + 1}. `;
242
+ if (focused) {
243
+ add(prefix + theme.fg("accent", `${num}${opt.title}`));
244
+ } else {
245
+ add(`${prefix}${theme.fg("text", `${num}${opt.title}`)}`);
246
+ }
247
+ if (opt.description) {
248
+ add(` ${theme.fg("muted", opt.description)}`);
249
+ }
250
+ }
190
251
 
191
- return {
192
- render,
193
- invalidate: () => {
194
- cachedLines = undefined;
195
- },
196
- handleInput,
197
- };
198
- }),
252
+ lines.push("");
253
+ add(theme.fg("dim", " Tab: plan ↔ options • Esc: cancel"));
254
+
255
+ if (useOverlay) {
256
+ add(theme.fg("accent", "─".repeat(width)));
257
+ }
258
+
259
+ cachedLines = lines;
260
+ return lines;
261
+ }
262
+
263
+ return {
264
+ render,
265
+ invalidate: () => {
266
+ cachedLines = undefined;
267
+ },
268
+ handleInput,
269
+ };
270
+ },
271
+ useOverlay
272
+ ? {
273
+ overlay: true,
274
+ overlayOptions: () => ({
275
+ anchor: "bottom-center",
276
+ width: "100%",
277
+ margin: { bottom: PLAN_APPROVAL_BOTTOM_RESERVE_LINES },
278
+ maxHeight: computePlanOverlayMaxHeight(overlayTermHeight),
279
+ }),
280
+ }
281
+ : undefined,
282
+ ),
199
283
  undefined,
200
284
  );
201
285
 
@@ -40,11 +40,24 @@ Otherwise use `HarnessSpawnContext` from `[HarnessRunContext]` for greenfield `m
40
40
  Agent({ subagent_type: "harness/planner", prompt: "<task + HarnessSpawnContext JSON + output schema>" })
41
41
  ```
42
42
 
43
- 3. `get_subagent_result` — parse final JSON (`status`, `plan_packet`, `human_summary`, `clarification`) via fenced `json` block.
44
- 4. If `status === "ready"` and parent `harness-run-context` shows `plan_ready: true` (planner called `create_plan`), confirm `plan_packet_path` exists — do **not** write the file yourself.
43
+ 3. `get_subagent_result` — parse final JSON (`status`, `plan_packet`, `human_summary`, `clarification`) via fenced `json` block. Treat `plan_packet` in that JSON as **read-only summary context** — not input for another approval tool call.
44
+ 4. If `status === "ready"` and `[HarnessRunContext]` shows `plan_ready: true` (planner called `create_plan`), confirm `plan_packet_path` exists — do **not** write the file yourself.
45
45
  5. If `needs_clarification`, tell the user the planner is waiting — do **not** re-spawn; user should answer in the subagent or re-run `/harness-plan`.
46
46
  6. Do **not** call `ask_user`, `approve_plan`, or `create_plan` in this parent session.
47
47
 
48
+ ## After subagent returns (no second approval)
49
+
50
+ User approval happens **once**, inside the planner subagent: `approve_plan` uses the parent TUI bridge. You are the orchestrator, **not** an approver.
51
+
52
+ After `get_subagent_result`:
53
+
54
+ - If `[HarnessRunContext]` shows `plan_ready: true`, or the transcript already has `harness-plan-approval` / bridged `approve_plan` with **Approve** → planning is complete. **Stop.** Summarize the plan and set `next_command: /harness-run`.
55
+ - Do **not** call `approve_plan` to “confirm” using `plan_packet` from subagent JSON.
56
+ - Do **not** call `ask_user` with Approve / Request changes / Cancel for the same plan.
57
+ - Do **not** re-spawn the planner to “get approval again”.
58
+
59
+ If `status === "ready"` but `plan_ready` is false → planner approved but `create_plan` may have failed; tell the user to run `/harness-plan-commit` — **not** a second `approve_plan`.
60
+
48
61
  ## Parent rules
49
62
 
50
63
  - Do not mutate project source files in the plan phase.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [v0.10.1] — 2026-05-17
8
+
9
+ ### 🐛 Fixes
10
+
11
+ - **Harness plan TUI:** agents widget stacks above harness-live; full-height bottom `approve_plan` overlay above the harness band; block duplicate parent `approve_plan` / plan `ask_user` after planner subagent approval.
12
+
7
13
  ## [v0.10.0] — 2026-05-17
8
14
 
9
15
  ### ✨ Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-pi",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Ultimate AI coding harness for pi.dev — extensible skills, Obsidian wiki knowledge layer, compressed context, deterministic output",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -82,7 +82,7 @@
82
82
  "format": "biome format --write",
83
83
  "format:check": "biome format",
84
84
  "prepare": "lefthook install",
85
- "test": "node --test test/harness-verify.test.mjs test/harness-ask-user.test.mjs test/harness-subagents-loader.test.mjs test/harness-subagents-import-path.test.mjs test/sentrux-rules-sync.test.mjs test/harness-budget-guard.test.mjs && npx -y tsx --test test/harness-vcc-settings.test.ts test/harness-plan-phase-policy.test.mjs test/harness-subagent-policy.test.mjs test/harness-turn-routing.test.mjs test/plan-approval-format.test.mjs test/plan-approval-sync.test.mjs test/plan-create-plan.test.mjs",
85
+ "test": "node --test test/harness-verify.test.mjs test/harness-ask-user.test.mjs test/harness-subagents-loader.test.mjs test/harness-subagents-import-path.test.mjs test/sentrux-rules-sync.test.mjs test/harness-budget-guard.test.mjs && npx -y tsx --test test/harness-vcc-settings.test.ts test/harness-plan-phase-policy.test.mjs test/harness-subagent-policy.test.mjs test/harness-turn-routing.test.mjs test/plan-approval-format.test.mjs test/plan-approval-dialog.test.mjs test/plan-approval-sync.test.mjs test/plan-create-plan.test.mjs",
86
86
  "test:vcc": "npx -y tsx --test vendor/pi-vcc/tests/*.test.ts",
87
87
  "harness:sentrux-bootstrap": "node .pi/scripts/harness-sentrux-bootstrap.mjs",
88
88
  "harness:sentrux-sync": "node .pi/scripts/sentrux-rules-sync.mjs --force",