@gavin-hu/my-pi-agent 0.11.4 → 0.13.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 (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -17,17 +17,17 @@
17
17
 
18
18
  import { StringEnum } from "@earendil-works/pi-ai";
19
19
  import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
20
- import { basename } from "node:path";
21
20
  import { Type, type Static } from "typebox";
22
21
  import { sanitizeLine } from "../../lib/format.ts";
23
- import { askHuman } from "../../lib/interaction.ts";
24
- import { TODO_TOOL } from "../../lib/tool-names.ts";
25
- import { FULL_SCREEN_OVERLAY, reuseText, toolDetails } from "../../lib/tui/chrome.ts";
22
+ import { lastGoal } from "../../lib/goal-state.ts";
23
+ import { GOAL_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
24
+ import { lastTodos } from "../../lib/todo-state.ts";
25
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
26
26
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
27
27
  import { paint } from "../../lib/tui/tokens.ts";
28
28
  import { expandHint, GLYPHS } from "../../lib/tui/glyphs.ts";
29
29
  import { READ_ONLY_SUMMARY } from "./policy.ts";
30
- import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
30
+ import { feedbackSummary } from "./feedback.ts";
31
31
  import { ENTER_TOOL, EXIT_TOOL, SET_PLAN_STEPS_TOOL, WRITE_PLAN_TOOL, type PlanRuntime } from "./runtime.ts";
32
32
  import {
33
33
  MAX_STEP_LENGTH,
@@ -35,14 +35,17 @@ import {
35
35
  PLAN_OUTLINE,
36
36
  extractPlanSteps,
37
37
  planStepSummary,
38
+ verificationItem,
38
39
  type PlanStep,
39
- type PlanStepSummary,
40
40
  type StepUpdate,
41
41
  } from "./steps.ts";
42
- import { DEFAULT_TODO_MAX, lastTodos, mergeTodos, type TodoMerge } from "./todos.ts";
43
- import { PlanViewComponent, sanitizePlanText, type PlanViewAction } from "./tui.ts";
42
+ import { basename } from "node:path";
43
+ import { planTitle, type StoredPlan } from "./plans.ts";
44
+ import { DEFAULT_TODO_MAX, mergeTodos, type TodoMerge } from "./todos.ts";
45
+ import { reviewPlan } from "./review.ts";
46
+ import { sanitizePlanText } from "./plan-text.ts";
44
47
  import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepsDetails, WritePlanDetails } from "./types.ts";
45
- import { createPlanWebReview, type PlanReviewResult, type PlanReviewSession, type PlanWebReview } from "./web.ts";
48
+ import { createPlanWebReview, type PlanWebReview } from "./web.ts";
46
49
 
47
50
  const ExitPlanModeParams = Type.Object({
48
51
  plan_path: Type.String({
@@ -116,20 +119,21 @@ function preview(plan: string): { body: string; more: number } {
116
119
  }
117
120
 
118
121
  /**
119
- * One sentence for `write_plan` when the step list is larger than approval can
120
- * track, so the model can shorten it instead of discovering the cap later.
122
+ * One sentence when a step is longer than the display cap. The text stays in the
123
+ * plan file either way; only the todo list and the approval listing shorten it.
121
124
  */
122
- function stepCapNote({ total, elided }: PlanStepSummary): string {
123
- const notes: string[] = [];
124
- if (total > MAX_STEPS) {
125
- notes.push(`the plan has ${total} steps and only the first ${MAX_STEPS} can be seeded or ticked`);
126
- }
127
- if (elided > 0) {
128
- notes.push(
129
- `${elided} step${elided === 1 ? " is" : "s are"} longer than ${MAX_STEP_LENGTH} characters and will be shortened in the todo list`,
130
- );
131
- }
132
- return notes.length > 0 ? ` Note: ${notes.join("; ")}.` : "";
125
+ function stepElisionNote(elided: number): string {
126
+ if (elided === 0) return "";
127
+ const plural = elided === 1 ? " is" : "s are";
128
+ return ` Note: ${elided} step${plural} longer than ${MAX_STEP_LENGTH} characters and will be shortened in the todo list.`;
129
+ }
130
+
131
+ /**
132
+ * The diagnosis both cap refusals share: a step list longer than approval can
133
+ * track. Each caller appends the fix that is available from where it stands.
134
+ */
135
+ function overCap(total: number): string {
136
+ return `The plan has ${total} top-level steps but approval tracks at most ${MAX_STEPS}.`;
133
137
  }
134
138
 
135
139
  /** "Step 2" or "Steps 1, 2" for a set of step numbers. */
@@ -171,11 +175,12 @@ async function seedTodos(
171
175
  plan: string,
172
176
  ): Promise<{ steps: PlanStep[]; merged?: TodoMerge; error?: string }> {
173
177
  const steps = extractPlanSteps(plan);
174
- if (steps.length === 0) return { steps };
175
- const merged = mergeTodos(lastTodos(ctx.sessionManager.getBranch()), steps, todoMaxItems(pi));
176
- // Nothing new to write: every step is already in the list (or the list is
177
- // full), so a whole-list rewrite would only churn the todo widget.
178
- if (merged.added === 0) return { steps, merged };
178
+ const verification = verificationItem(plan);
179
+ if (steps.length === 0 && !verification) return { steps };
180
+ const merged = mergeTodos(lastTodos(ctx.sessionManager.getBranch()), steps, todoMaxItems(pi), verification);
181
+ // Nothing new to write: every step (and the verification item) is already in
182
+ // the list, or the list is full, so a rewrite would only churn the widget.
183
+ if (merged.added === 0 && merged.trailing !== "added") return { steps, merged };
179
184
  const outcome = await ctx.executeTool(TODO_TOOL, { todos: merged.todos });
180
185
  if (!outcome.isError) return { steps, merged };
181
186
  // The nested call fails both when the tool is unavailable and when it rejects
@@ -185,6 +190,56 @@ async function seedTodos(
185
190
  return { steps, merged, error: firstLine ? sanitizeLine(firstLine) : undefined };
186
191
  }
187
192
 
193
+ /** What the goal offer did: `recorded` undefined when there was nothing to offer. */
194
+ interface GoalOffer {
195
+ objective: string;
196
+ recorded?: boolean;
197
+ error?: string;
198
+ }
199
+
200
+ /**
201
+ * Offer to record the approved plan as the session goal — only when the session
202
+ * has no goal at all (an active or achieved one is the user's, never replaced) and
203
+ * the session can ask. The objective is the plan's own `# title`, or the file-name
204
+ * slug; recording runs through the goal tool, so validation and the widget stay
205
+ * goal's, and no model turn is spent.
206
+ */
207
+ async function offerSessionGoal(ctx: ExtensionToolContext, plan: StoredPlan): Promise<GoalOffer> {
208
+ const objective = planObjective(plan);
209
+ if (!ctx.hasUI) return { objective };
210
+ // The branch already carries a goal: leave it alone, and say nothing.
211
+ if (lastGoal(ctx.sessionManager.getBranch()) !== null) return { objective };
212
+ const approved = await ctx.ui.confirm("Record this plan as the session goal?", objective);
213
+ if (!approved) return { objective };
214
+ const outcome = await ctx.executeTool(GOAL_TOOL, { objective });
215
+ if (!outcome.isError) return { objective, recorded: true };
216
+ const part = outcome.result?.content?.find((entry) => entry.type === "text");
217
+ const firstLine = part?.type === "text" ? part.text.split("\n", 1)[0] : undefined;
218
+ return { objective, recorded: false, error: firstLine ? sanitizeLine(firstLine) : undefined };
219
+ }
220
+
221
+ /** The plan's own title: its first `# heading`, else the file-name slug. */
222
+ function planObjective(plan: StoredPlan): string {
223
+ const heading = /^ {0,3}#\s+(.+)$/m.exec(plan.content)?.[1];
224
+ const title = heading === undefined ? "" : sanitizeLine(heading).trim();
225
+ return title || planTitle(basename(plan.path));
226
+ }
227
+
228
+ /**
229
+ * One sentence about the plan's verification item, or "" when the plan has none
230
+ * (or it was already in the list). The item is a todo, not a plan step, so it is
231
+ * ticked in the list rather than in the file.
232
+ */
233
+ function verificationNote(merged: TodoMerge | undefined): string {
234
+ if (merged?.trailing === "added") {
235
+ return "\nThe plan's verification section is the last todo item; tick it in the todo list, not in the file.";
236
+ }
237
+ if (merged?.trailing === "deferred") {
238
+ return "\nThe plan's verification section did not fit in the full todo list; keep it in mind.";
239
+ }
240
+ return "";
241
+ }
242
+
188
243
  /** What the merge did, for the approval message; `steps` is the plan's step count. */
189
244
  function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
190
245
  const total = steps.length;
@@ -204,103 +259,6 @@ function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
204
259
  return ` Merged ${counted} into the todo list${suffix}. ${tick}`;
205
260
  }
206
261
 
207
- /** The review choice plus the refinement the user typed in the TUI, if any. */
208
- interface PlanReviewOutcome {
209
- action: PlanViewAction;
210
- refinement?: string;
211
- }
212
-
213
- /** A dialog-only choice: post the full plan text, then ask again. Never returned. */
214
- type ReviewChoice = PlanReviewOutcome | { action: "send" };
215
-
216
- /** Prompt shown by the dialog-based refine editor in non-TUI modes. */
217
- const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, Esc to cancel)";
218
-
219
- /** How much of one step the remote digest shows. */
220
- const DIGEST_STEP_CHARS = 90;
221
-
222
- /**
223
- * A one-message digest of the plan for a remote review.
224
- *
225
- * The file is the primary artifact, but a phone client may never render or open
226
- * it, and the peer still has to choose approve / refine. The digest carries the
227
- * title, the step count, and the first few steps; the full text follows as a
228
- * file or as the fallback message.
229
- */
230
- function planReviewDigest(content: string): string {
231
- const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
232
- const steps = extractPlanSteps(content);
233
- const count = steps.length;
234
- const head = `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
235
- const preview = steps.slice(0, 3).map((step, index) => {
236
- const text = step.content.replace(/\s+/g, " ").trim();
237
- const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
238
- return `${index + 1}. ${shown}`;
239
- });
240
- const more = count > preview.length ? `… (${count - preview.length} more)` : "";
241
- return [head, ...preview, more, "The full plan is attached above, or sent as text if the file failed."]
242
- .filter((line) => line !== "")
243
- .join("\n");
244
- }
245
-
246
- /**
247
- * Ask the user to review the plan file. The TUI opens the scrollable review
248
- * screen (with an inline refine editor); dialog-capable modes (RPC) fall back
249
- * to a select plus the refine editor; the caller has already refused when there
250
- * is no UI.
251
- */
252
- async function reviewPlan(
253
- ctx: ExtensionToolContext,
254
- plan: { path: string; relativePath: string; content: string; bytes: number },
255
- previous?: string,
256
- ): Promise<PlanReviewOutcome> {
257
- // The first pass sends the file (or the full text when it fails) and the
258
- // digest; asking for the full plan re-enters with the text only, then shows
259
- // the choices again.
260
- let sentFullText = false;
261
- for (;;) {
262
- const outcome = await askHuman<ReviewChoice>(ctx, {
263
- custom: async () => {
264
- const outcome = await ctx.ui.custom<PlanReviewOutcome | undefined>(
265
- (tui, theme, _keybindings, done) =>
266
- new PlanViewComponent({
267
- plan,
268
- previous,
269
- theme,
270
- onClose: (action, refinement) => done({ action, refinement }),
271
- requestRender: () => tui.requestRender(),
272
- viewportRows: () => tui.terminal?.rows,
273
- tui,
274
- }),
275
- FULL_SCREEN_OVERLAY,
276
- );
277
- return outcome ?? { action: "keep" };
278
- },
279
- dialogs: async () => {
280
- const choice = await ctx.ui.select("Plan mode — what next?", [
281
- "Approve and execute",
282
- "Send the full plan",
283
- "Refine the plan",
284
- "Keep planning",
285
- ]);
286
- if (choice === "Approve and execute") return { action: "approve" };
287
- if (choice === "Send the full plan") return { action: "send" };
288
- if (choice === "Refine the plan") return { action: "refine" };
289
- return { action: "keep" };
290
- },
291
- // A remote turn gets the plan file up front, falling back to the full text;
292
- // a local TUI turn ignores both and renders the component. The digest goes
293
- // out either way so the choices stay answerable when a phone client does not
294
- // render the file bubble.
295
- file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
296
- body: plan.content,
297
- note: sentFullText ? undefined : planReviewDigest(plan.content),
298
- });
299
- if (outcome.action !== "send") return outcome;
300
- sentFullText = true;
301
- }
302
- }
303
-
304
262
  export function registerTools(
305
263
  pi: ExtensionAPI,
306
264
  runtime: PlanRuntime,
@@ -320,7 +278,7 @@ export function registerTools(
320
278
  executionMode: "sequential",
321
279
  // readOnlyHint stays true: entering plan mode changes only the session's
322
280
  // tool set, never files or system state, so it needs no approval.
323
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
281
+ annotations: { readOnlyHint: true, openWorldHint: false },
324
282
 
325
283
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
326
284
  if (runtime.isEnabled()) {
@@ -384,7 +342,8 @@ export function registerTools(
384
342
  "Save the plan you designed as a markdown file under .pi/plans (project root, or the agent directory outside a " +
385
343
  "repository). Write the complete plan, not a summary, in this outline so it is consistent and scannable:\n\n" +
386
344
  `${PLAN_OUTLINE}\n\n` +
387
- "Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining.",
345
+ "Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining. " +
346
+ `A plan with more than ${MAX_STEPS} top-level steps in its Steps section is refused, so approval never drops steps it cannot track.`,
388
347
  promptSnippet: "Save the designed plan as a markdown file under .pi/plans.",
389
348
  promptGuidelines: [
390
349
  "While plan mode is active, write the full plan with write_plan, then call exit_plan_mode with the returned path.",
@@ -405,6 +364,21 @@ export function registerTools(
405
364
  };
406
365
  }
407
366
  const args = params as WritePlanArgs;
367
+ const summary = planStepSummary(args.content);
368
+ if (summary.total > MAX_STEPS) {
369
+ // Refuse before writing: a note after the fact arrives once the file exists and
370
+ // the review has listed every step, so the overflow is already lost.
371
+ return {
372
+ content: [
373
+ {
374
+ type: "text",
375
+ text: `${overCap(summary.total)} Nothing was written; group the steps (one outcome each, sub-bullets for detail) and call write_plan again.`,
376
+ },
377
+ ],
378
+ details: undefined,
379
+ isError: true,
380
+ };
381
+ }
408
382
  try {
409
383
  const file = await runtime.plans.write(ctx.cwd, {
410
384
  title: args.title,
@@ -416,7 +390,7 @@ export function registerTools(
416
390
  content: [
417
391
  {
418
392
  type: "text",
419
- text: `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${stepCapNote(planStepSummary(args.content))}`,
393
+ text: `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${stepElisionNote(summary.elided)}`,
420
394
  },
421
395
  ],
422
396
  details: { path: file.path, relativePath: file.relativePath, bytes: file.bytes } satisfies WritePlanDetails,
@@ -464,9 +438,9 @@ export function registerTools(
464
438
  executionMode: "sequential",
465
439
  // readOnlyHint stays true: it writes no files, and the review dialog inside
466
440
  // execute is the human gate, so an outer prompt would only double-ask.
467
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
441
+ annotations: { readOnlyHint: true, openWorldHint: false },
468
442
 
469
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
443
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
470
444
  const planPath = (params as ExitPlanModeArgs).plan_path.trim();
471
445
 
472
446
  if (!runtime.isEnabled()) {
@@ -477,7 +451,7 @@ export function registerTools(
477
451
  };
478
452
  }
479
453
 
480
- let file = await runtime.plans.read(ctx.cwd, planPath);
454
+ const file = await runtime.plans.read(ctx.cwd, planPath);
481
455
  if (!file) {
482
456
  return {
483
457
  content: [
@@ -491,6 +465,27 @@ export function registerTools(
491
465
  };
492
466
  }
493
467
 
468
+ const total = planStepSummary(file.content).total;
469
+ if (total > MAX_STEPS) {
470
+ // A hand-edited or older plan file can still be over the cap; refuse it here
471
+ // too, so approval never seeds a list it cannot hold in full.
472
+ return {
473
+ content: [
474
+ {
475
+ type: "text",
476
+ text: `${overCap(total)} It was not presented for approval; call write_plan with plan_path to group the steps, then exit_plan_mode again.`,
477
+ },
478
+ ],
479
+ details: {
480
+ approved: false,
481
+ plan: file.content,
482
+ planPath: file.path,
483
+ relativePath: file.relativePath,
484
+ } satisfies ExitPlanModeDetails,
485
+ isError: true,
486
+ };
487
+ }
488
+
494
489
  if (!ctx.hasUI) {
495
490
  return {
496
491
  content: [
@@ -510,117 +505,58 @@ export function registerTools(
510
505
  };
511
506
  }
512
507
 
513
- // What the last review showed this file, so this one can mark the changes.
514
- // Mark before presenting: the next review compares against what is on
515
- // screen now, not against a revision the user never saw.
516
- let presentedPrevious: string | undefined;
517
- const present = (candidate: { path: string; relativePath: string; content: string; bytes: number }) => {
518
- const previous = runtime.reviewed(candidate.path);
519
- runtime.markReviewed(candidate.path, candidate.content);
520
- presentedPrevious = previous;
521
- return reviewPlan(ctx, candidate, previous);
522
- };
523
-
524
- let outcome = await present(file);
525
- // Comments carried by a browser approval, reported alongside the approval.
526
- let browserComments: PlanAnnotation[] = [];
527
- // Review, refine, and browser review all loop back here until the user
528
- // approves, keeps planning, or a refinement is handed to the model. The
529
- // dialog-based refine editor carries no text from the review screen, so an
530
- // empty or cancelled editor returns to the review instead of being reported
531
- // as "not approved".
532
- for (;;) {
533
- if (outcome.action === "browser") {
534
- // The review screen that produced this choice already marked the
535
- // content it showed, so the page compares against that baseline.
536
- const session: PlanReviewSession | undefined = await web
537
- .start(ctx.cwd, file, { canApprove: true, previous: presentedPrevious })
538
- .catch(() => undefined);
539
- if (!session) {
540
- ctx.ui.notify("Plan review: could not start the browser review server.", "error");
541
- outcome = await present(file);
542
- continue;
543
- }
544
- ctx.ui.notify(`Plan review: opened ${session.url}`, "info");
545
- const result: PlanReviewResult = await session.done;
546
- await session.close();
547
- if (result.status === "approved") {
548
- // Approve from the browser is the TUI's approve: keep the comments it
549
- // carried and fall through to the shared approval path below.
550
- file = result.plan;
551
- browserComments = result.annotations;
552
- outcome = { action: "approve" };
553
- continue;
554
- }
555
- if (result.status === "saved") {
556
- // The comments are already in the file; hand them back as a refinement so
557
- // the model revises the same plan and calls exit_plan_mode again.
558
- const refinement = feedbackSummary(result.annotations) || "See the review comments in the plan file.";
559
- return {
560
- content: [
561
- {
562
- type: "text",
563
- text: `The user reviewed the plan in the browser and left feedback:\n${refinement}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`,
564
- },
565
- ],
566
- details: {
567
- approved: false,
568
- plan: result.plan.content,
569
- planPath: result.plan.path,
570
- relativePath: result.plan.relativePath,
571
- refined: true,
572
- refinement,
573
- } satisfies ExitPlanModeDetails,
574
- };
575
- }
576
- // Cancelled or the tab closed: re-read the file and review again.
577
- file = (await runtime.plans.read(ctx.cwd, file.path)) ?? file;
578
- outcome = await present(file);
579
- continue;
580
- }
581
- if (outcome.action === "refine" && !outcome.refinement) {
582
- const refinement = (await ctx.ui.editor(REFINE_PROMPT, ""))?.trim();
583
- if (refinement) outcome = { action: "refine", refinement };
584
- else outcome = await present(file);
585
- continue;
586
- }
587
- if (outcome.action === "refine") {
588
- const refinement = outcome.refinement ?? "";
589
- return {
590
- content: [
591
- {
592
- type: "text",
593
- text: `The user asked to refine the plan: ${refinement}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`,
594
- },
595
- ],
596
- details: {
597
- approved: false,
598
- plan: file.content,
599
- planPath: file.path,
600
- relativePath: file.relativePath,
601
- refined: true,
602
- refinement,
603
- } satisfies ExitPlanModeDetails,
604
- };
605
- }
606
- if (outcome.action !== "approve") {
607
- return {
608
- content: [
609
- { type: "text", text: "Plan not approved. Stay in plan mode, ask what to change, and revise the plan." },
610
- ],
611
- details: {
612
- approved: false,
613
- plan: file.content,
614
- planPath: file.path,
615
- relativePath: file.relativePath,
616
- } satisfies ExitPlanModeDetails,
617
- };
618
- }
619
- break;
508
+ // One decision, however the session is attached; `review.ts` owns the loop,
509
+ // the baseline, and closing the browser review when the turn is aborted.
510
+ const decision = await reviewPlan({
511
+ ctx,
512
+ web,
513
+ plans: runtime.plans,
514
+ baselines: runtime,
515
+ plan: file,
516
+ signal,
517
+ });
518
+
519
+ if (decision.action === "refine") {
520
+ // A browser save already wrote its comments into the file; either way the
521
+ // model revises the same plan and presents it again.
522
+ const feedback = decision.fromBrowser
523
+ ? `The user reviewed the plan in the browser and left feedback:\n${decision.refinement}`
524
+ : `The user asked to refine the plan: ${decision.refinement}`;
525
+ return {
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: `${feedback}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`,
530
+ },
531
+ ],
532
+ details: {
533
+ approved: false,
534
+ plan: decision.plan.content,
535
+ planPath: decision.plan.path,
536
+ relativePath: decision.plan.relativePath,
537
+ refined: true,
538
+ refinement: decision.refinement,
539
+ annotations: decision.comments.length > 0 ? decision.comments : undefined,
540
+ } satisfies ExitPlanModeDetails,
541
+ };
542
+ }
543
+ if (decision.action === "keep") {
544
+ return {
545
+ content: [
546
+ { type: "text", text: "Plan not approved. Stay in plan mode, ask what to change, and revise the plan." },
547
+ ],
548
+ details: {
549
+ approved: false,
550
+ plan: decision.plan.content,
551
+ planPath: decision.plan.path,
552
+ relativePath: decision.plan.relativePath,
553
+ } satisfies ExitPlanModeDetails,
554
+ };
620
555
  }
621
556
 
622
557
  runtime.disable(ctx);
623
- const { steps, merged, error } = await seedTodos(pi, ctx, file.content);
558
+ const { steps, merged, error } = await seedTodos(pi, ctx, decision.plan.content);
559
+ const goal = await offerSessionGoal(ctx, decision.plan);
624
560
  const listing =
625
561
  steps.length > 0
626
562
  ? `\n\n${steps
@@ -635,21 +571,29 @@ export function registerTools(
635
571
  : merged
636
572
  ? mergeSummary(merged, steps)
637
573
  : ` Could not read the todo list. Record these steps with the todo tool before you start, and tick them off with ${SET_PLAN_STEPS_TOOL}.`;
638
- const browserNote = browserComments.length
639
- ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(browserComments)}`
574
+ const verification = verificationNote(merged);
575
+ const goalNote =
576
+ goal.recorded === undefined
577
+ ? ""
578
+ : goal.recorded
579
+ ? ` Recorded the session goal: ${goal.objective}.`
580
+ : ` Could not record the session goal: ${goal.error ?? "unknown error"}. Use the goal tool to set it.`;
581
+ const browserNote = decision.comments.length
582
+ ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(decision.comments)}`
640
583
  : "";
641
584
  return {
642
585
  content: [
643
586
  {
644
587
  type: "text",
645
- text: `Plan approved. Plan mode is off and write access is restored. The plan file ${file.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${listing}`,
588
+ text: `Plan approved. Plan mode is off and write access is restored. The plan file ${decision.plan.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${verification}${goalNote}${listing}`,
646
589
  },
647
590
  ],
648
591
  details: {
649
592
  approved: true,
650
- plan: file.content,
651
- planPath: file.path,
652
- relativePath: file.relativePath,
593
+ plan: decision.plan.content,
594
+ planPath: decision.plan.path,
595
+ relativePath: decision.plan.relativePath,
596
+ annotations: decision.comments.length > 0 ? decision.comments : undefined,
653
597
  } satisfies ExitPlanModeDetails,
654
598
  };
655
599
  },