@gavin-hu/my-pi-agent 0.11.3 → 0.12.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.
Files changed (86) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +22 -11
  73. package/extensions/wechat/bridge.ts +90 -22
  74. package/extensions/wechat/commands.ts +45 -1
  75. package/extensions/wechat/doctor.ts +1 -0
  76. package/extensions/wechat/index.ts +3 -1
  77. package/extensions/wechat/status-screen.ts +32 -2
  78. package/extensions/wechat/tools.ts +0 -2
  79. package/lib/README.md +2 -1
  80. package/lib/config.ts +43 -1
  81. package/lib/env.ts +12 -0
  82. package/lib/requirements.ts +76 -2
  83. package/lib/todo-state.ts +62 -0
  84. package/lib/tui/glyphs.ts +1 -0
  85. package/package.json +1 -1
  86. package/assets/bytefly.schema.json +0 -785
@@ -51,10 +51,18 @@ export function goalObjective(goal: Goal, theme: Theme): string {
51
51
  return goal.status === "achieved" ? paint(theme, "faint", goal.objective) : paint(theme, "label", goal.objective);
52
52
  }
53
53
 
54
- /** Model-facing result text. */
55
- export function formatGoalText(goal: Goal | null): string {
54
+ /**
55
+ * Model-facing result text. `openTodos` counts the todo items the branch still
56
+ * has open, so marking a goal achieved can point at work the list still holds.
57
+ */
58
+ export function formatGoalText(goal: Goal | null, openTodos = 0): string {
56
59
  if (goal === null) return "Goal cleared.";
57
- if (goal.status === "achieved") return `Goal achieved: ${goal.objective}`;
60
+ if (goal.status === "achieved") {
61
+ const text = `Goal achieved: ${goal.objective}`;
62
+ if (openTodos <= 0) return text;
63
+ const items = openTodos === 1 ? "1 todo item is" : `${openTodos} todo items are`;
64
+ return `${text}\n${items} still open; close or clear the list if that work is done.`;
65
+ }
58
66
  return `Goal: ${goal.objective}\nKeep this objective in mind as you work, and mark it achieved with the goal tool when it is done.`;
59
67
  }
60
68
 
@@ -72,7 +80,7 @@ export const GOAL_HELP = [
72
80
  "Set a goal with /goal <what you want to accomplish> and start working on it.",
73
81
  "/goal raw <prompt> records your words verbatim, without confirming them first.",
74
82
  "/goal set <prompt> records the goal without starting work; /goal edit [<prompt>] changes it.",
75
- "/goal done · /goal clear · /goal help",
83
+ "/goal done (or achieved) · /goal clear · /goal help (or -h / --help)",
76
84
  ].join("\n");
77
85
 
78
86
  /** Truncate an objective to `max` display columns, appending `…` when cut. */
@@ -11,6 +11,7 @@ import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { sanitizeLine } from "../../lib/format.ts";
13
13
  import { GOAL_TOOL } from "../../lib/tool-names.ts";
14
+ import { lastTodos } from "../../lib/todo-state.ts";
14
15
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
16
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
16
17
  import { paint } from "../../lib/tui/tokens.ts";
@@ -18,7 +19,7 @@ import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
19
  import { formatCallText, formatGoalText } from "./format.ts";
19
20
  import type { GoalRuntime } from "./runtime.ts";
20
21
  import { GoalParams, GoalResult, normalizeGoal, type GoalArgs } from "./schema.ts";
21
- import { GoalResult as GoalResultView } from "./tui.ts";
22
+ import { GoalHeader } from "./tui.ts";
22
23
  import type { GoalAction, GoalDetails } from "./types.ts";
23
24
 
24
25
  export const TOOL_NAME = GOAL_TOOL;
@@ -45,13 +46,30 @@ function toStructuredContent(details: GoalDetails): Record<string, JsonValue> {
45
46
  return content;
46
47
  }
47
48
 
49
+ /**
50
+ * How many items the branch's todo list still has open.
51
+ *
52
+ * `todo` is the *how* to the goal's *what*, so completing one while the other
53
+ * still holds work is worth saying out loud. The list is read from the branch
54
+ * (`lib/todo-state.ts`), so no todo module is imported.
55
+ */
56
+ function openTodoCount(entries: Iterable<unknown>): number {
57
+ return lastTodos(entries).filter((todo) => todo.status !== "completed").length;
58
+ }
59
+
48
60
  /**
49
61
  * Build the tool result for one call. A rejected call (`error` set) carries the
50
62
  * unchanged goal so the model can retry, and is marked `isError`.
51
63
  */
52
- function goalResult(details: GoalDetails, error?: string) {
64
+ function goalResult(details: GoalDetails, options: { error?: string; openTodos?: number } = {}) {
65
+ const { error, openTodos = 0 } = options;
53
66
  return {
54
- content: [{ type: "text" as const, text: error === undefined ? formatGoalText(details.goal) : `Error: ${error}` }],
67
+ content: [
68
+ {
69
+ type: "text" as const,
70
+ text: error === undefined ? formatGoalText(details.goal, openTodos) : `Error: ${error}`,
71
+ },
72
+ ],
55
73
  details,
56
74
  structuredContent: toStructuredContent(details),
57
75
  ...(error === undefined ? {} : { isError: true as const }),
@@ -82,10 +100,14 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
82
100
  const goal = normalizeGoal(args);
83
101
  const action = goal === null ? "clear" : goal.status === "achieved" ? "achieve" : "set";
84
102
  runtime.setGoal(goal, ctx);
85
- return goalResult({ goal, action });
103
+ const openTodos = action === "achieve" ? openTodoCount(ctx.sessionManager.getBranch()) : 0;
104
+ return goalResult({ goal, action }, { openTodos });
86
105
  } catch (error) {
87
106
  const message = error instanceof Error ? error.message : String(error);
88
- return goalResult({ goal: runtime.getGoal(), action: attemptedAction(args), error: message }, message);
107
+ return goalResult(
108
+ { goal: runtime.getGoal(), action: attemptedAction(args), error: message },
109
+ { error: message },
110
+ );
89
111
  }
90
112
  },
91
113
 
@@ -115,9 +137,7 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
115
137
  return text;
116
138
  }
117
139
  const view =
118
- context.lastComponent instanceof GoalResultView
119
- ? context.lastComponent
120
- : new GoalResultView(details.goal, theme);
140
+ context.lastComponent instanceof GoalHeader ? context.lastComponent : new GoalHeader(details.goal, theme);
121
141
  view.update(details.goal, theme);
122
142
  return view;
123
143
  },
@@ -44,7 +44,7 @@ export class GoalWidget implements Component {
44
44
  }
45
45
 
46
46
  /** Transcript result block: the status header, separated from the call line. */
47
- export class GoalResult implements Component {
47
+ export class GoalHeader implements Component {
48
48
  private goal: Goal;
49
49
  private theme: Theme;
50
50
 
@@ -173,7 +173,7 @@ when consolidating.
173
173
  |---|---|
174
174
  | Registration | The default export registers the `memory` tool, the `/memory` command, and four event listeners; nothing long-lived. |
175
175
  | Events | `session_start` loads config and both stores; `before_agent_start` injects the `[MEMORY]` message; `context` keeps only the newest injection; `session_shutdown` clears the cache. The runtime refreshes both stores from their file stamps whenever the notes are read. |
176
- | Tool | `exposure: "direct"`, `executionMode: "sequential"`, `annotations` and `outputSchema` as above. |
176
+ | Tool | direct exposure (the default), `executionMode: "sequential"`, `annotations` and `outputSchema` as above. |
177
177
  | State | External markdown, **not** branch state: the tool result's `details`/`structuredContent` mirror the call but the notes are read from disk and survive independently of `/resume` and `/tree`. |
178
178
  | UI | `ctx.ui.notify`, `editor`, and `confirm`, guarded by `ctx.hasUI`, plus the `/memory` picker (`ctx.ui.custom`) in the TUI. |
179
179
  | Plan mode | Blocked — `plan`'s read-only policy allows only `readOnlyHint` tools, so memory cannot be mutated while planning; recall still works. |
@@ -174,6 +174,10 @@ instead of opening a server nobody can use. The browser's **Approve & run** is
174
174
  offered only for the review `exit_plan_mode` opens, since only that flow owns
175
175
  leaving plan mode; `/plan review` stays comment-only.
176
176
 
177
+ Aborting the turn closes a browser review. `exit_plan_mode` forwards the call's
178
+ abort signal to the review server, so an aborted turn returns "not approved"
179
+ and stops listening instead of waiting on a page nobody is watching.
180
+
177
181
  ### Transcript
178
182
 
179
183
  The three tool rows reuse the transcript component, and `exit_plan_mode`
@@ -227,8 +231,11 @@ command. The read-only guarantee is layered:
227
231
  transcript and in non-TUI notices; a plan file cannot drive the terminal.
228
232
  - **Loopback browser review.** The review server binds `127.0.0.1` and requires a
229
233
  random per-session token in the URL, rejects a missing or foreign `Host`
230
- header, serves only `GET`/`POST`, and caps the request body; the page renders plan and comment text
231
- with DOM text nodes, never `innerHTML`, so a plan cannot script the reviewer.
234
+ header, serves only `GET`, `HEAD`, and `POST`, and caps the request body; the
235
+ page renders plan and comment text with DOM text nodes, never `innerHTML`, so a
236
+ plan cannot script the reviewer. The page also refuses to be framed
237
+ (`frame-ancestors 'none'` and `X-Frame-Options: DENY`), so a local page cannot
238
+ clickjack **Approve & run**.
232
239
 
233
240
  ## Read-only git shell
234
241
 
@@ -356,12 +363,13 @@ file and returns them to the model.
356
363
  update, depending on the mode).
357
364
  - **Loopback and tokenized.** The server binds `127.0.0.1`, carries a random
358
365
  per-session token in the URL, rejects a missing or foreign `Host` header,
359
- serves only `GET` and `POST`, and caps the request body. A save or cancel
366
+ serves only `GET`, `HEAD`, and `POST`, and caps the request body. A save or cancel
360
367
  resolves the session only after its HTTP response is delivered, so the browser
361
368
  never sees a false "Failed to fetch". The last bound port is remembered per
362
369
  plan, so reviewing the same plan again reuses the origin (and the browser's
363
- saved theme with it). It is closed on save, cancel, tab close, or
364
- `session_shutdown`.
370
+ saved theme with it). It is closed on save, cancel, a dropped event stream, or
371
+ `session_shutdown`; a reload reconnects inside that grace, so refreshing or
372
+ reloading a conflicted page does not end the review.
365
373
 
366
374
  | Route | Result |
367
375
  |---|---|
@@ -406,7 +414,7 @@ file and returns them to the model.
406
414
 
407
415
  | Integration point | How |
408
416
  |---|---|
409
- | Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_steps` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`); no `outputSchema`/`structuredContent`. |
417
+ | Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_steps` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`), and `ExitPlanModeDetails` carries the review's `annotations` when a browser approval or save supplied comments; no `outputSchema`/`structuredContent`. |
410
418
  | Commands and flags | `/plan` (toggle, or enter and send a task), `/plan review [path]` (browser review), `--plan` launch flag, `Ctrl+Alt+P` shortcut. |
411
419
  | State | Enabled flag and the tools it hid persisted with `pi.appendEntry("plan-mode", { enabled, removed })` and rebuilt from `ctx.sessionManager.getBranch()`; the last plan path lives in runtime memory for the footer chip, and a bare `/plan review` falls back to the newest plan file in the directory. The content each review showed is kept in runtime memory (keyed by resolved path) for the changed markers. |
412
420
  | Hooks | `tool_call` blocks non-read-only calls and forces `subagent` read-only; `before_agent_start` injects the hidden `[PLAN MODE ACTIVE]` context; `context` keeps only the newest plan context while enabled and drops stale ones otherwise. |
@@ -414,6 +422,13 @@ file and returns them to the model.
414
422
 
415
423
  ## Design notes
416
424
 
425
+ - **One review loop owns the human gate.** `review.ts` asks however the session
426
+ is attached — TUI screen, dialog plus editor, remote file plus digest, or
427
+ browser — and returns one `ReviewDecision`, so `exit_plan_mode` and
428
+ `/plan review` no longer carry two copies of the browser lifecycle and the
429
+ tool's own `for(;;)` with a `continue` per outcome. The loop also owns the
430
+ baseline rule (mark what the screen showed, after it showed it) and forwards
431
+ the call's abort signal, which is what closes a review on an aborted turn.
417
432
  - **Two tools, not one.** `EnterPlanMode`/`ExitPlanMode` are a pair: entering is
418
433
  consent to explore read-only, exiting is consent to act. A single toggle tool
419
434
  cannot express "the model wants to plan but the user declines".
@@ -500,8 +515,9 @@ file and returns them to the model.
500
515
  | `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, atomic `write`/`setSteps`/`saveFeedback`, and `read`/`latest`. |
501
516
  | `todos.ts` | Approval-time todo merge (pure): the last list replayed from the branch, and the merge of the plan's steps into it with the cap respected. |
502
517
  | `steps.ts` | Plan-document structure (pure): the outline, step extraction and the `Steps`-section scope, heading sections, the section diff, and the checkbox edit. |
503
- | `runtime.ts` | Enabled state, tool gating, persistence, control-tool identity, footer status. |
504
- | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`. |
518
+ | `runtime.ts` | Enabled state, tool gating, persistence, control-tool identity, footer status, the review baseline. |
519
+ | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`: schemas, results, transcripts, and the approval-time todo seed. |
520
+ | `review.ts` | The review gate: one decision from the TUI screen, a dialog, or the browser, the shared browser lifecycle, and the "mark what was shown" baseline rule. |
505
521
  | `commands.ts` | `/plan` (enter, optional task) and the `/plan review [path]` browser subcommand. |
506
522
  | `tui.ts` | `PlanViewComponent` (sectioned Markdown body, changed-section summary and marker, section jump, scroll gauge, approve/refine/browser/keep). |
507
523
  | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, tables, rules, blanks). |
@@ -14,7 +14,7 @@
14
14
  * step.
15
15
  */
16
16
 
17
- import { normalizeHeadingText } from "./steps.ts";
17
+ import { FENCE, HEADING } from "./steps.ts";
18
18
 
19
19
  /** How a block is rendered and grouped. */
20
20
  export type PlanBlockKind = "heading" | "paragraph" | "list" | "code" | "quote" | "table" | "hr" | "blank";
@@ -30,10 +30,6 @@ export interface PlanBlock {
30
30
  lines: string[];
31
31
  }
32
32
 
33
- /** ATX heading outside a fence. */
34
- const HEADING = /^ {0,3}#{1,6}(?:\s|$)/;
35
- /** Code-fence opener or closer (backticks or tildes). */
36
- const FENCE = /^ {0,3}(`{3,}|~{3,})/;
37
33
  /** A list marker with its leading indentation and trailing gap. */
38
34
  const LIST_ITEM = /^( *)([-*+]|\d+[.)])(\s+)/;
39
35
  /** A blockquote line. */
@@ -190,36 +186,6 @@ export function blockAt(blocks: PlanBlock[], line: number): PlanBlock | undefine
190
186
  return candidate;
191
187
  }
192
188
 
193
- /** A heading in a plan, for the review page's section outline. */
194
- export interface PlanHeading {
195
- /** 0-based source line of the heading. */
196
- line: number;
197
- /** Heading level, 1-6. */
198
- level: number;
199
- /** Heading text with inline markdown removed. */
200
- text: string;
201
- }
202
-
203
- /** ATX heading with its captured level and raw text. */
204
- const HEADING_PARTS = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
205
-
206
- /** Strip the inline markdown an outline label should not show. */
207
- function headingText(raw: string): string {
208
- return normalizeHeadingText(raw);
209
- }
210
-
211
- /** The plan's headings in order, for the outline. */
212
- export function planHeadings(blocks: PlanBlock[]): PlanHeading[] {
213
- const headings: PlanHeading[] = [];
214
- for (const block of blocks) {
215
- if (block.kind !== "heading") continue;
216
- const match = block.lines[0]?.match(HEADING_PARTS);
217
- if (!match) continue;
218
- headings.push({ line: block.start, level: match[1].length, text: headingText(match[2]) });
219
- }
220
- return headings;
221
- }
222
-
223
189
  /** The source inside a fenced code block, without its opening and closing fence lines. */
224
190
  export function codeBody(block: PlanBlock): string[] {
225
191
  if (block.kind !== "code") return block.lines.slice();
@@ -11,6 +11,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
11
11
  import { isRemoteTurn } from "../../lib/interaction.ts";
12
12
  import { feedbackSummary } from "./feedback.ts";
13
13
  import { READ_ONLY_SUMMARY } from "./policy.ts";
14
+ import { runBrowserReview } from "./review.ts";
14
15
  import type { PlanRuntime } from "./runtime.ts";
15
16
  import { createPlanWebReview, type PlanWebReview } from "./web.ts";
16
17
 
@@ -57,34 +58,39 @@ async function reviewInBrowser(
57
58
  // to the newest plan file in the plans directory.
58
59
  const file = path ? await runtime.plans.read(ctx.cwd, path) : await runtime.plans.latest(ctx.cwd);
59
60
  if (!file) {
60
- if (path) ctx.ui.notify(`plan review: no plan file at ${path}`, "error");
61
- else ctx.ui.notify("plan review: no plan to review; write one with write_plan or pass a path.", "warning");
61
+ if (path) ctx.ui.notify(`Plan review: no plan file at ${path}`, "error");
62
+ else ctx.ui.notify("Plan review: no plan to review; write one with write_plan or pass a path.", "warning");
62
63
  return;
63
64
  }
64
65
  // The server is loopback-only, so a remote turn cannot drive it.
65
66
  if (isRemoteTurn(ctx)) {
66
- ctx.ui.notify("plan review: browser review needs the local machine.", "warning");
67
+ ctx.ui.notify("Plan review: browser review needs the local machine.", "warning");
67
68
  return;
68
69
  }
69
70
 
70
71
  const previous = runtime.reviewed(file.path);
71
- runtime.markReviewed(file.path, file.content);
72
- const session = await web.start(ctx.cwd, file, { previous }).catch(() => undefined);
73
- if (!session) {
74
- ctx.ui.notify("plan review: could not start the browser review server.", "error");
72
+ const result = await runBrowserReview(web, ctx.cwd, file, {
73
+ canApprove: false,
74
+ previous,
75
+ // Mark only once the page is actually open: a failed start must not consume
76
+ // the baseline for a plan nobody saw.
77
+ onOpened: (session) => {
78
+ runtime.markReviewed(file.path, file.content);
79
+ ctx.ui.notify(`Plan review: opened ${session.url}`, "info");
80
+ },
81
+ });
82
+ if (!result) {
83
+ ctx.ui.notify("Plan review: could not start the browser review server.", "error");
75
84
  return;
76
85
  }
77
- ctx.ui.notify(`plan review: opened ${session.url}`, "info");
78
- const result = await session.done;
79
- await session.close();
80
86
 
81
87
  if (result.status === "cancelled") {
82
- ctx.ui.notify("plan review: closed without saving.", "info");
88
+ ctx.ui.notify("Plan review: closed without saving.", "info");
83
89
  return;
84
90
  }
85
91
  const summary = feedbackSummary(result.annotations);
86
92
  if (!summary) {
87
- ctx.ui.notify("plan review: saved with no comments.", "info");
93
+ ctx.ui.notify("Plan review: saved with no comments.", "info");
88
94
  return;
89
95
  }
90
96
  const message = runtime.isEnabled()
@@ -295,7 +295,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
295
295
  /**
296
296
  * Write the browser review comments into an existing plan, replacing any
297
297
  * feedback already stored there. The file must already be a contained plan,
298
- * so this is the same path-safe write as `setStep`.
298
+ * so this is the same path-safe write as `setSteps`.
299
299
  */
300
300
  const saveFeedback = async (cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan> => {
301
301
  const plan = await read(cwd, planPath);
@@ -7,9 +7,10 @@
7
7
  * from `index.ts` so tests and future read-only modes can build the policy
8
8
  * without loading the extension wiring.
9
9
  *
10
- * Plan mode keeps raw shell on a short leash: `bash` and `powershell` stay
11
- * active only so the model can run read-only git commands, and the `tool_call`
12
- * guard in `index.ts` refuses anything that is not one (see `git-bash.ts`).
10
+ * Plan mode keeps raw shell on a short leash: `bash` stays active only so the
11
+ * model can run read-only git commands (the `tool_call` guard in `index.ts`
12
+ * refuses anything that is not one, see `git-bash.ts`), and `powershell` is
13
+ * blocked outright so plan mode has one shell to reason about.
13
14
  */
14
15
 
15
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The plan review gate: ask the human about a plan file and return one decision.
3
+ *
4
+ * A review has three presentations — the scrollable TUI screen, a select plus
5
+ * editor for dialog-only modes, and a remote turn's plan file plus digest — and
6
+ * one optional browser server. This module owns the loop that switches between
7
+ * them, so `exit_plan_mode` and `/plan review` share one lifecycle and only map
8
+ * the returned {@link ReviewDecision} to their own result.
9
+ *
10
+ * It also owns the review baseline: a review marks the content the human was
11
+ * actually shown, never content that was only about to be shown, and the abort
12
+ * signal reaches the browser server so an aborted turn closes it.
13
+ */
14
+
15
+ import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
16
+ import { basename } from "node:path";
17
+ import { askHuman } from "../../lib/interaction.ts";
18
+ import { FULL_SCREEN_OVERLAY } from "../../lib/tui/chrome.ts";
19
+ import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
20
+ import type { PlanStore, StoredPlan } from "./plans.ts";
21
+ import { extractPlanSteps } from "./steps.ts";
22
+ import { PlanViewComponent, type PlanViewAction } from "./tui.ts";
23
+ import type { PlanReviewResult, PlanReviewSession, PlanWebReview } from "./web.ts";
24
+
25
+ /** A choice from the review screen, with the refinement the screen carried, if any. */
26
+ interface PlanReviewChoice {
27
+ action: PlanViewAction;
28
+ refinement?: string;
29
+ }
30
+
31
+ /** A dialog-only choice: post the full plan text, then ask again. Never returned. */
32
+ type ReviewChoice = PlanReviewChoice | { action: "send" };
33
+
34
+ /** What a review decided, and the plan state it decided on. */
35
+ export type ReviewDecision =
36
+ | { action: "approve"; plan: StoredPlan; comments: PlanAnnotation[] }
37
+ | {
38
+ action: "refine";
39
+ plan: StoredPlan;
40
+ /** The refinement request: the saved comments, or what the user typed. */
41
+ refinement: string;
42
+ comments: PlanAnnotation[];
43
+ /** True when the request came from the browser, which is reported differently. */
44
+ fromBrowser: boolean;
45
+ }
46
+ | { action: "keep"; plan: StoredPlan };
47
+
48
+ /** What the previous review showed, so this one can mark what changed. */
49
+ export interface ReviewBaselines {
50
+ reviewed(path: string): string | undefined;
51
+ markReviewed(path: string, content: string): void;
52
+ }
53
+
54
+ /** Everything one review needs: whom to ask, where to ask, and what to compare against. */
55
+ export interface ReviewPlanOptions {
56
+ ctx: ExtensionToolContext;
57
+ web: PlanWebReview;
58
+ /** Re-read the plan after a browser review that may have changed it. */
59
+ plans: Pick<PlanStore, "read">;
60
+ baselines: ReviewBaselines;
61
+ plan: StoredPlan;
62
+ /** Aborting the turn closes a browser review and returns the plan unapproved. */
63
+ signal?: AbortSignal;
64
+ }
65
+
66
+ /** Prompt shown by the dialog-based refine editor in non-TUI modes. */
67
+ const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, Esc to cancel)";
68
+
69
+ /** How much of one step the remote digest shows. */
70
+ const DIGEST_STEP_CHARS = 90;
71
+
72
+ /** The refinement request used when a browser save carried no comments. */
73
+ const NO_BROWSER_COMMENTS = "See the review comments in the plan file.";
74
+
75
+ /**
76
+ * A one-message digest of the plan for a remote review.
77
+ *
78
+ * The file is the primary artifact, but a phone client may never render or open
79
+ * it, and the peer still has to choose approve / refine. The digest carries the
80
+ * title, the step count, and the first few steps; the full text follows as a
81
+ * file or as the fallback message.
82
+ */
83
+ function planReviewDigest(content: string): string {
84
+ const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
85
+ const steps = extractPlanSteps(content);
86
+ const count = steps.length;
87
+ const head = `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
88
+ const preview = steps.slice(0, 3).map((step, index) => {
89
+ const text = step.content.replace(/\s+/g, " ").trim();
90
+ const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
91
+ return `${index + 1}. ${shown}`;
92
+ });
93
+ const more = count > preview.length ? `… (${count - preview.length} more)` : "";
94
+ return [head, ...preview, more, "The full plan is attached above, or sent as text if the file failed."]
95
+ .filter((line) => line !== "")
96
+ .join("\n");
97
+ }
98
+
99
+ /**
100
+ * Ask the user to review the plan file. The TUI opens the scrollable review
101
+ * screen (with an inline refine editor); dialog-capable modes (RPC) fall back to
102
+ * a select plus the refine editor; a remote turn gets the plan file and a digest
103
+ * first. Asking for the full plan re-enters with the text only, then shows the
104
+ * choices again.
105
+ */
106
+ async function askPlanReview(
107
+ ctx: ExtensionToolContext,
108
+ plan: StoredPlan,
109
+ previous: string | undefined,
110
+ ): Promise<PlanReviewChoice> {
111
+ let sentFullText = false;
112
+ for (;;) {
113
+ const outcome = await askHuman<ReviewChoice>(ctx, {
114
+ custom: async () => {
115
+ const choice = await ctx.ui.custom<PlanReviewChoice | undefined>(
116
+ (tui, theme, _keybindings, done) =>
117
+ new PlanViewComponent({
118
+ plan,
119
+ previous,
120
+ theme,
121
+ onClose: (action, refinement) => done({ action, refinement }),
122
+ requestRender: () => tui.requestRender(),
123
+ viewportRows: () => tui.terminal?.rows,
124
+ tui,
125
+ }),
126
+ FULL_SCREEN_OVERLAY,
127
+ );
128
+ return choice ?? { action: "keep" };
129
+ },
130
+ dialogs: async () => {
131
+ const choice = await ctx.ui.select("Plan mode — what next?", [
132
+ "Approve and execute",
133
+ "Send the full plan",
134
+ "Refine the plan",
135
+ "Keep planning",
136
+ ]);
137
+ if (choice === "Approve and execute") return { action: "approve" };
138
+ if (choice === "Send the full plan") return { action: "send" };
139
+ if (choice === "Refine the plan") return { action: "refine" };
140
+ return { action: "keep" };
141
+ },
142
+ // A remote turn gets the plan file up front, falling back to the full text;
143
+ // a local TUI turn ignores both and renders the component. The digest goes
144
+ // out either way so the choices stay answerable when a phone client does not
145
+ // render the file bubble.
146
+ file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
147
+ body: plan.content,
148
+ note: sentFullText ? undefined : planReviewDigest(plan.content),
149
+ });
150
+ if (outcome.action !== "send") return outcome;
151
+ sentFullText = true;
152
+ }
153
+ }
154
+
155
+ /** How a browser review may decide, and what to report when it opens. */
156
+ export interface BrowserReviewOptions {
157
+ /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
158
+ canApprove: boolean;
159
+ /** The content the previous review showed, so the page can mark changed sections. */
160
+ previous?: string;
161
+ /** Aborting the turn closes the session as cancelled. */
162
+ signal?: AbortSignal;
163
+ /** Called once the server is listening and the browser was opened. */
164
+ onOpened?: (session: PlanReviewSession) => void;
165
+ }
166
+
167
+ /**
168
+ * Open a browser review and wait for its outcome. `undefined` means the server
169
+ * could not start, so the caller can fall back to the screen it came from.
170
+ */
171
+ export async function runBrowserReview(
172
+ web: PlanWebReview,
173
+ cwd: string,
174
+ plan: StoredPlan,
175
+ options: BrowserReviewOptions,
176
+ ): Promise<PlanReviewResult | undefined> {
177
+ if (options.signal?.aborted) return { status: "cancelled" };
178
+ const session = await web
179
+ .start(cwd, plan, { canApprove: options.canApprove, previous: options.previous, signal: options.signal })
180
+ .catch(() => undefined);
181
+ if (!session) return undefined;
182
+ options.onOpened?.(session);
183
+ try {
184
+ return await session.done;
185
+ } finally {
186
+ await session.close();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Ask the user about `plan` and return the decision. Approve and keep end the
192
+ * review; a refinement is handed back to the model; a browser review that is
193
+ * cancelled returns to the screen it was opened from.
194
+ */
195
+ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDecision> {
196
+ const { ctx, web, plans, baselines, signal } = options;
197
+ let plan = options.plan;
198
+ for (;;) {
199
+ // An aborted turn has nobody left to answer the screen.
200
+ if (signal?.aborted) return { action: "keep", plan };
201
+ // Read the baseline before the screen and mark it after, so what the next
202
+ // review compares against is the content this one actually showed.
203
+ const previous = baselines.reviewed(plan.path);
204
+ const choice = await askPlanReview(ctx, plan, previous);
205
+ baselines.markReviewed(plan.path, plan.content);
206
+
207
+ if (choice.action === "approve") return { action: "approve", plan, comments: [] };
208
+ if (choice.action === "keep") return { action: "keep", plan };
209
+ if (choice.action === "refine") {
210
+ const refinement = choice.refinement ?? (await ctx.ui.editor(REFINE_PROMPT, ""))?.trim();
211
+ // An empty or cancelled editor returns to the review screen instead of
212
+ // being reported as "not approved".
213
+ if (!refinement) continue;
214
+ return { action: "refine", plan, refinement, comments: [], fromBrowser: false };
215
+ }
216
+ // An unrecognized action keeps planning rather than opening a server.
217
+ if (choice.action !== "browser") return { action: "keep", plan };
218
+
219
+ const result = await runBrowserReview(web, ctx.cwd, plan, {
220
+ canApprove: true,
221
+ previous,
222
+ signal,
223
+ onOpened: (session) => ctx.ui.notify(`Plan review: opened ${session.url}`, "info"),
224
+ });
225
+ if (!result) {
226
+ ctx.ui.notify("Plan review: could not start the browser review server.", "error");
227
+ continue;
228
+ }
229
+ if (result.status === "approved") return { action: "approve", plan: result.plan, comments: result.annotations };
230
+ if (result.status === "saved") {
231
+ return {
232
+ action: "refine",
233
+ plan: result.plan,
234
+ refinement: feedbackSummary(result.annotations) || NO_BROWSER_COMMENTS,
235
+ comments: result.annotations,
236
+ fromBrowser: true,
237
+ };
238
+ }
239
+ // Cancelled or the tab closed: the file may have been rewritten underneath,
240
+ // so re-read it before showing the screen again.
241
+ plan = (await plans.read(ctx.cwd, plan.path)) ?? plan;
242
+ }
243
+ }
@@ -177,6 +177,10 @@ export function createPlanRuntime(
177
177
  toggle: (ctx) => setEnabled(!enabled, ctx, true),
178
178
  restore: (ctx) => {
179
179
  lastPlanPath = undefined;
180
+ // The baseline belongs to the branch that was reviewed: a resumed or
181
+ // tree-restored session starts without one rather than marking every section
182
+ // changed against a review this branch never showed.
183
+ reviewedPlans.clear();
180
184
  const persisted = readPersistedState(ctx);
181
185
  // `--plan` starts a fresh session in plan mode; an explicit persisted
182
186
  // choice (including a later disable) wins over the launch flag.