@gavin-hu/my-pi-agent 0.12.0 → 0.13.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.
Files changed (167) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +162 -70
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +38 -6
  7. package/extensions/bytefly/footer/budget.ts +64 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
  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 +16 -0
  12. package/extensions/bytefly/footer/price/types.ts +5 -0
  13. package/extensions/bytefly/footer/register.ts +46 -4
  14. package/extensions/bytefly/footer/render/lines.ts +2 -0
  15. package/extensions/bytefly/footer/render/segments.ts +27 -0
  16. package/extensions/bytefly/footer/types.ts +11 -0
  17. package/extensions/bytefly/index.ts +8 -5
  18. package/extensions/bytefly/notify/config.ts +2 -0
  19. package/extensions/bytefly/notify/format.ts +19 -1
  20. package/extensions/bytefly/notify/register.ts +52 -14
  21. package/extensions/bytefly/notify/types.ts +2 -0
  22. package/extensions/bytefly/panel/commands.ts +34 -13
  23. package/extensions/bytefly/panel/settings.ts +129 -16
  24. package/extensions/bytefly/update/cache.ts +73 -0
  25. package/extensions/bytefly/update/format.ts +65 -10
  26. package/extensions/bytefly/update/register.ts +99 -13
  27. package/extensions/bytefly/update/state.ts +13 -1
  28. package/extensions/document/README.md +22 -12
  29. package/extensions/document/config.ts +4 -0
  30. package/extensions/document/deadline.ts +61 -0
  31. package/extensions/document/tool.ts +13 -2
  32. package/extensions/file-browse/README.md +12 -8
  33. package/extensions/file-browse/client.ts +6 -7
  34. package/extensions/file-browse/files.ts +0 -4
  35. package/extensions/file-browse/git.ts +9 -10
  36. package/extensions/file-browse/html.ts +17 -7
  37. package/extensions/file-browse/router.ts +24 -15
  38. package/extensions/goal/README.md +7 -1
  39. package/extensions/goal/schema.ts +6 -2
  40. package/extensions/goal/types.ts +6 -2
  41. package/extensions/job/README.md +16 -12
  42. package/extensions/job/format.ts +4 -7
  43. package/extensions/job/runtime.ts +23 -6
  44. package/extensions/job/status.ts +4 -2
  45. package/extensions/memory/README.md +55 -13
  46. package/extensions/memory/commands.ts +20 -2
  47. package/extensions/memory/doctor.ts +137 -0
  48. package/extensions/memory/format.ts +24 -7
  49. package/extensions/memory/runtime.ts +49 -0
  50. package/extensions/memory/store.ts +31 -0
  51. package/extensions/memory/tui.ts +136 -12
  52. package/extensions/plan/README.md +60 -29
  53. package/extensions/plan/commands.ts +67 -8
  54. package/extensions/plan/index.ts +35 -11
  55. package/extensions/plan/plan-index.ts +234 -0
  56. package/extensions/plan/plan-text.ts +95 -0
  57. package/extensions/plan/plans.ts +90 -1
  58. package/extensions/plan/review.ts +12 -9
  59. package/extensions/plan/runtime.ts +72 -27
  60. package/extensions/plan/steps.ts +35 -25
  61. package/extensions/plan/todos.ts +24 -2
  62. package/extensions/plan/tools.ts +124 -24
  63. package/extensions/plan/tui.ts +7 -28
  64. package/extensions/plan/types.ts +9 -0
  65. package/extensions/plan/web-page.ts +2 -2
  66. package/extensions/plan/web.ts +12 -11
  67. package/extensions/rewind/README.md +28 -8
  68. package/extensions/rewind/commands.ts +1 -1
  69. package/extensions/rewind/format.ts +4 -4
  70. package/extensions/rewind/git.ts +56 -18
  71. package/extensions/rewind/index.ts +3 -5
  72. package/extensions/rewind/path-picker.ts +198 -0
  73. package/extensions/rewind/paths.ts +128 -0
  74. package/extensions/rewind/restore.ts +69 -28
  75. package/extensions/rewind/rewind.ts +80 -3
  76. package/extensions/rewind/runtime.ts +13 -5
  77. package/extensions/rewind/snapshot-run.ts +5 -2
  78. package/extensions/rewind/types.ts +2 -0
  79. package/extensions/subagent/README.md +7 -1
  80. package/extensions/subagent/accounting.ts +34 -0
  81. package/extensions/subagent/index.ts +23 -1
  82. package/extensions/subagent/stream.ts +2 -0
  83. package/extensions/subagent/types.ts +2 -0
  84. package/extensions/todo/nudge.ts +5 -1
  85. package/extensions/watch/README.md +19 -8
  86. package/extensions/watch/commands.ts +4 -3
  87. package/extensions/watch/config.ts +1 -1
  88. package/extensions/watch/format.ts +4 -7
  89. package/extensions/watch/index.ts +19 -0
  90. package/extensions/watch/runtime.ts +83 -12
  91. package/extensions/web-access/README.md +43 -13
  92. package/extensions/web-access/charset.ts +58 -0
  93. package/extensions/web-access/fetch/find.ts +11 -7
  94. package/extensions/web-access/fetch/format.ts +64 -18
  95. package/extensions/web-access/fetch/page.ts +5 -1
  96. package/extensions/web-access/fetch/tool.ts +19 -18
  97. package/extensions/web-access/http.ts +49 -8
  98. package/extensions/web-access/search/facts.ts +38 -0
  99. package/extensions/web-access/search/tool.ts +13 -0
  100. package/extensions/web-access/text.ts +50 -11
  101. package/extensions/worktree/README.md +81 -16
  102. package/extensions/worktree/config.ts +11 -0
  103. package/extensions/worktree/enter.ts +10 -3
  104. package/extensions/worktree/exit.ts +55 -3
  105. package/extensions/worktree/gaps.ts +74 -0
  106. package/extensions/worktree/git.ts +28 -0
  107. package/extensions/worktree/guard.ts +50 -4
  108. package/extensions/worktree/handoff.ts +55 -0
  109. package/extensions/worktree/index.ts +14 -2
  110. package/extensions/worktree/root-tools.ts +1 -1
  111. package/extensions/worktree/runtime.ts +25 -0
  112. package/extensions/worktree/status.ts +15 -2
  113. package/extensions/worktree/tools.ts +3 -2
  114. package/lib/README.md +14 -10
  115. package/lib/events.ts +86 -1
  116. package/lib/goal-state.ts +95 -0
  117. package/lib/requirements.ts +361 -62
  118. package/lib/tui/README.md +91 -0
  119. package/lib/tui/columns.ts +17 -0
  120. package/lib/tui/glyphs.ts +1 -0
  121. package/lib/tui/tool.ts +6 -2
  122. package/lib/web/layout.ts +12 -2
  123. package/package.json +5 -3
  124. package/themes/bytefly-dark.json +1 -1
  125. package/assets/chrome.png +0 -0
  126. package/assets/file-browse.png +0 -0
  127. package/assets/job.png +0 -0
  128. package/assets/plan-review.png +0 -0
  129. package/assets/question.png +0 -0
  130. package/assets/rewind.png +0 -0
  131. package/assets/todo.png +0 -0
  132. package/assets/tool-renderings.png +0 -0
  133. package/assets/tools/ask_user_question-cancelled.png +0 -0
  134. package/assets/tools/ask_user_question.png +0 -0
  135. package/assets/tools/bash.png +0 -0
  136. package/assets/tools/edit.png +0 -0
  137. package/assets/tools/enter_plan_mode.png +0 -0
  138. package/assets/tools/enter_worktree.png +0 -0
  139. package/assets/tools/exit_plan_mode.png +0 -0
  140. package/assets/tools/exit_worktree.png +0 -0
  141. package/assets/tools/find.png +0 -0
  142. package/assets/tools/goal.png +0 -0
  143. package/assets/tools/grep.png +0 -0
  144. package/assets/tools/job.png +0 -0
  145. package/assets/tools/list_worktrees.png +0 -0
  146. package/assets/tools/ls.png +0 -0
  147. package/assets/tools/memory-long-subject.png +0 -0
  148. package/assets/tools/memory.png +0 -0
  149. package/assets/tools/notice.png +0 -0
  150. package/assets/tools/open_path.png +0 -0
  151. package/assets/tools/prune_worktrees.png +0 -0
  152. package/assets/tools/read.png +0 -0
  153. package/assets/tools/read_doc-running.png +0 -0
  154. package/assets/tools/read_doc.png +0 -0
  155. package/assets/tools/run_doctor.png +0 -0
  156. package/assets/tools/send_wechat.png +0 -0
  157. package/assets/tools/set_plan_steps.png +0 -0
  158. package/assets/tools/subagent.png +0 -0
  159. package/assets/tools/todo.png +0 -0
  160. package/assets/tools/turn-rule.png +0 -0
  161. package/assets/tools/user-message.png +0 -0
  162. package/assets/tools/watch.png +0 -0
  163. package/assets/tools/web_fetch-rejected.png +0 -0
  164. package/assets/tools/web_fetch.png +0 -0
  165. package/assets/tools/web_search.png +0 -0
  166. package/assets/tools/write.png +0 -0
  167. package/assets/tools/write_plan.png +0 -0
@@ -180,6 +180,37 @@ export function applyUpdate(content: string, from: string, to: string): FileChan
180
180
  return changed ? { content: lines.join("\n"), changed: true } : { content, changed: false };
181
181
  }
182
182
 
183
+ /**
184
+ * Merge two notes into one: replace `first` in place with `merged` and drop
185
+ * `second`, so the pair becomes a single bullet where the first one stood. One
186
+ * transform, so the caller's write is atomic; a missing note or two identical
187
+ * names changes nothing.
188
+ */
189
+ export function applyMerge(content: string, first: string, second: string, merged: string): FileChange {
190
+ if (first === second) return { content, changed: false };
191
+ const lines = content.split(/\r?\n/);
192
+ const notes = lines.map((line) => {
193
+ const match = BULLET_PARTS.exec(line);
194
+ return match ? sanitizeLine(match[2]) : undefined;
195
+ });
196
+ if (!notes.includes(first) || !notes.includes(second)) return { content, changed: false };
197
+
198
+ let changed = false;
199
+ const out: string[] = [];
200
+ for (const line of lines) {
201
+ const match = BULLET_PARTS.exec(line);
202
+ if (match && sanitizeLine(match[2]) === first) {
203
+ out.push(`${match[1]}${merged}${match[3]}`);
204
+ changed = true;
205
+ continue;
206
+ }
207
+ if (match && sanitizeLine(match[2]) === second) continue;
208
+ out.push(line);
209
+ }
210
+
211
+ return changed ? { content: endWithNewline(out.join("\n")), changed: true } : { content, changed: false };
212
+ }
213
+
183
214
  /** Remove every note, keeping the header and any prose. */
184
215
  export function applyClear(content: string): FileChange {
185
216
  let changed = false;
@@ -7,6 +7,12 @@
7
7
  * without the component owning any authoritative state. Navigation and scroll
8
8
  * math come from `lib/tui/cursor.ts` and the screen chrome from `lib/tui/chrome.ts`;
9
9
  * the inline note editor is the shared `Editor` from `@earendil-works/pi-tui`.
10
+ *
11
+ * `m` marks one note as a merge source and `m` on a second note in the same
12
+ * scope opens the editor, pre-filled with both notes so the user writes the
13
+ * merged wording. The merge is one write: the store replaces the first note and
14
+ * drops the second. `p` moves the focused note to the other scope, which is two
15
+ * writes ordered target-first so a failure leaves a duplicate, never a loss.
10
16
  */
11
17
 
12
18
  import type { Theme } from "@earendil-works/pi-coding-agent";
@@ -53,6 +59,10 @@ export interface MemoryPickerCallbacks {
53
59
  add(scope: MemoryScope, text: string): Promise<{ changed: boolean; existing?: string }>;
54
60
  update(scope: MemoryScope, from: string, to: string): Promise<boolean>;
55
61
  forget(scope: MemoryScope, entry: string): Promise<boolean>;
62
+ /** Merge two notes in `scope` into one, in a single write. */
63
+ merge(scope: MemoryScope, first: string, second: string, merged: string): Promise<boolean>;
64
+ /** Move a note to the other scope: written there first, then removed here. */
65
+ move(from: MemoryScope, to: MemoryScope, entry: string): Promise<{ moved: boolean; existing?: string }>;
56
66
  }
57
67
 
58
68
  /** One note in the flat, filtered list. */
@@ -72,11 +82,15 @@ export class MemoryPickerComponent implements Component {
72
82
  private readonly editor: Editor;
73
83
 
74
84
  private filter: MemoryFilter = "all";
75
- private mode: "list" | "edit" | "add" = "list";
85
+ private mode: "list" | "edit" | "add" | "merge" = "list";
76
86
  /** Scope the inline editor writes to. */
77
87
  private editorScope: MemoryScope = "project";
78
88
  /** Note being edited, or undefined while adding. */
79
89
  private editing: Note | undefined;
90
+ /** The two notes a merge editor will combine. */
91
+ private mergePair: { first: Note; second: Note } | undefined;
92
+ /** The note marked with `m`, awaiting a second `m` to merge into it. */
93
+ private mergeSource: Note | undefined;
80
94
  /** Note awaiting a y/N forget confirmation. */
81
95
  private confirm: Note | undefined;
82
96
  /** Transient feedback shown in place of the hint. */
@@ -161,15 +175,55 @@ export class MemoryPickerComponent implements Component {
161
175
  }
162
176
  this.mode = "edit";
163
177
  this.editing = note;
178
+ this.mergePair = undefined;
164
179
  this.editorScope = note.scope;
165
180
  this.editor.setText(note.text);
166
181
  this.syncEditorFocus();
167
182
  this.requestRender();
168
183
  }
169
184
 
185
+ /**
186
+ * `m`: mark the focused note, unmark it, or open the merge editor for a marked
187
+ * pair. The mark survives navigation so the second note can be found.
188
+ */
189
+ private toggleMerge(note: Note | undefined): void {
190
+ if (!note) return;
191
+ const source = this.mergeSource;
192
+ if (source && noteKey(source) === noteKey(note)) {
193
+ this.mergeSource = undefined;
194
+ this.requestRender();
195
+ return;
196
+ }
197
+ if (source) {
198
+ this.startMerge(source, note);
199
+ return;
200
+ }
201
+ this.mergeSource = note;
202
+ this.notice = undefined;
203
+ this.requestRender();
204
+ }
205
+
206
+ /** Open the editor for a marked pair, pre-filled with both notes to rewrite. */
207
+ private startMerge(first: Note, second: Note): void {
208
+ if (first.scope !== second.scope) {
209
+ this.notice = "A merge combines two notes in the same scope.";
210
+ this.requestRender();
211
+ return;
212
+ }
213
+ this.mode = "merge";
214
+ this.editing = undefined;
215
+ this.mergePair = { first, second };
216
+ this.editorScope = first.scope;
217
+ // The user writes the merged wording; the seed only saves retyping both notes.
218
+ this.editor.setText(`${first.text}; ${second.text}`.slice(0, MAX_NOTE_CHARS));
219
+ this.syncEditorFocus();
220
+ this.requestRender();
221
+ }
222
+
170
223
  private startAdd(): void {
171
224
  this.mode = "add";
172
225
  this.editing = undefined;
226
+ this.mergePair = undefined;
173
227
  this.editorScope = this.activeScope();
174
228
  this.editor.setText("");
175
229
  this.syncEditorFocus();
@@ -179,6 +233,8 @@ export class MemoryPickerComponent implements Component {
179
233
  private cancelEditor(): void {
180
234
  this.mode = "list";
181
235
  this.editing = undefined;
236
+ this.mergePair = undefined;
237
+ this.mergeSource = undefined;
182
238
  this.syncEditorFocus();
183
239
  this.notice = undefined;
184
240
  this.requestRender();
@@ -188,8 +244,11 @@ export class MemoryPickerComponent implements Component {
188
244
  const text = sanitizeLine(value).trim();
189
245
  const editing = this.editing;
190
246
  const scope = this.editorScope;
247
+ const pair = this.mergePair;
191
248
  this.mode = "list";
192
249
  this.editing = undefined;
250
+ this.mergePair = undefined;
251
+ this.mergeSource = undefined;
193
252
  this.syncEditorFocus();
194
253
 
195
254
  if (text.length === 0) {
@@ -204,6 +263,12 @@ export class MemoryPickerComponent implements Component {
204
263
  }
205
264
 
206
265
  this.run(async () => {
266
+ if (pair) {
267
+ await this.callbacks.merge(pair.first.scope, pair.first.text, pair.second.text, text);
268
+ this.notice = `Merged 2 ${scopeLabel(pair.first.scope)} notes.`;
269
+ this.selectedKey = noteKey({ scope: pair.first.scope, text });
270
+ return;
271
+ }
207
272
  if (editing) {
208
273
  if (text === editing.text) {
209
274
  this.notice = "Unchanged.";
@@ -236,6 +301,30 @@ export class MemoryPickerComponent implements Component {
236
301
  });
237
302
  }
238
303
 
304
+ /** `p`: move the focused note to the other scope, target first. */
305
+ private moveNote(note: Note | undefined): void {
306
+ if (!note) return;
307
+ const to: MemoryScope = note.scope === "project" ? "global" : "project";
308
+ if (to === "project" && !this.callbacks.trusted()) {
309
+ this.notice = "Project memory is unavailable for an untrusted project.";
310
+ this.requestRender();
311
+ return;
312
+ }
313
+ const from = note.scope;
314
+ const text = note.text;
315
+ this.run(async () => {
316
+ const result = await this.callbacks.move(from, to, text);
317
+ if (!result.moved) {
318
+ this.notice = `Added to ${scopeLabel(to)}; the ${scopeLabel(from)} note was already gone.`;
319
+ } else if (result.existing !== undefined) {
320
+ this.notice = `Moved to ${scopeLabel(to)} (it was already there).`;
321
+ } else {
322
+ this.notice = `Moved to ${scopeLabel(to)}.`;
323
+ }
324
+ this.selectedKey = noteKey({ scope: to, text });
325
+ });
326
+ }
327
+
239
328
  private reload(): void {
240
329
  this.notice = this.callbacks.refresh() ? "Reloaded memory." : "Memory is already up to date.";
241
330
  this.requestRender();
@@ -248,11 +337,21 @@ export class MemoryPickerComponent implements Component {
248
337
  .catch((error: unknown) => {
249
338
  this.notice = `Memory not saved: ${messageOf(error)}`;
250
339
  })
251
- .then(() => this.requestRender());
340
+ .then(() => {
341
+ this.dropStaleMergeSource();
342
+ this.requestRender();
343
+ });
344
+ }
345
+
346
+ /** A merge source that a mutation removed is no longer a valid target. */
347
+ private dropStaleMergeSource(): void {
348
+ const source = this.mergeSource;
349
+ if (!source) return;
350
+ if (!this.notes().some((note) => noteKey(note) === noteKey(source))) this.mergeSource = undefined;
252
351
  }
253
352
 
254
353
  private syncEditorFocus(): void {
255
- this.editor.focused = this.componentFocused && (this.mode === "edit" || this.mode === "add");
354
+ this.editor.focused = this.componentFocused && this.mode !== "list";
256
355
  }
257
356
 
258
357
  private handleConfirm(data: string): void {
@@ -270,7 +369,7 @@ export class MemoryPickerComponent implements Component {
270
369
  }
271
370
 
272
371
  handleInput(data: string): void {
273
- if (this.mode === "edit" || this.mode === "add") {
372
+ if (this.mode !== "list") {
274
373
  if (isCloseKey(data) || matchesKey(data, Key.escape)) {
275
374
  this.cancelEditor();
276
375
  return;
@@ -284,6 +383,12 @@ export class MemoryPickerComponent implements Component {
284
383
  return;
285
384
  }
286
385
  if (isCloseKey(data) || data === "q") {
386
+ // With a mark set, the first Esc cancels the merge instead of closing the screen.
387
+ if (this.mergeSource) {
388
+ this.mergeSource = undefined;
389
+ this.requestRender();
390
+ return;
391
+ }
287
392
  this.onClose();
288
393
  return;
289
394
  }
@@ -308,6 +413,8 @@ export class MemoryPickerComponent implements Component {
308
413
  if (matchesKey(data, Key.tab) || data === "s") this.cycleFilter();
309
414
  else if (matchesKey(data, Key.enter) || data === "e") this.startEdit(focused);
310
415
  else if (data === "a") this.startAdd();
416
+ else if (data === "m") this.toggleMerge(focused);
417
+ else if (data === "p") this.moveNote(focused);
311
418
  else if (data === "d") {
312
419
  if (!focused) return;
313
420
  this.confirm = focused;
@@ -355,9 +462,11 @@ export class MemoryPickerComponent implements Component {
355
462
  /** One note row: selection marker, scope tag, and the sanitized note text. */
356
463
  private noteRow(note: Note, selected: boolean, width: number): string {
357
464
  const tag = `${paint(this.theme, "meta", scopeLabel(note.scope))} ${paint(this.theme, "faint", "·")}`;
465
+ const source = this.mergeSource;
466
+ const marked = source !== undefined && noteKey(source) === noteKey(note);
358
467
  return listRow(this.theme, width, {
359
468
  selected,
360
- leading: tag,
469
+ leading: marked ? `${tag} ${paint(this.theme, "warn", "merge")}` : tag,
361
470
  label: sanitizeLine(note.text),
362
471
  labelRole: selected ? "accent" : "label",
363
472
  });
@@ -372,17 +481,20 @@ export class MemoryPickerComponent implements Component {
372
481
  }
373
482
 
374
483
  private renderEditor(width: number): string[] {
375
- const verb = this.mode === "edit" ? "Edit" : "Add";
484
+ const verb = this.mode === "edit" ? "Edit" : this.mode === "merge" ? "Merge" : "Add";
376
485
  const scope = scopeLabel(this.editorScope);
377
486
  const pad = width > 1 ? " " : "";
487
+ const summary =
488
+ this.mode === "edit"
489
+ ? "Editing the focused note"
490
+ : this.mode === "merge"
491
+ ? `Merging 2 ${scope} notes into one`
492
+ : `New ${scope} note`;
378
493
  const body: string[] = [];
379
494
  for (const line of this.editor.render(Math.max(1, width - 2))) body.push(`${pad}${line}`);
380
495
  return dockListFrame({
381
496
  header: titleRule(this.theme, width, `Memory · ${verb} ${scope}`),
382
- summary: clip(
383
- ` ${paint(this.theme, "meta", this.mode === "edit" ? "Editing the focused note" : `New ${scope} note`)}`,
384
- width,
385
- ),
497
+ summary: clip(` ${paint(this.theme, "meta", summary)}`, width),
386
498
  body,
387
499
  hint: screenHint(this.theme, width, ["Enter save", "Esc cancel"]),
388
500
  });
@@ -391,7 +503,7 @@ export class MemoryPickerComponent implements Component {
391
503
  render(width: number): string[] {
392
504
  const w = Math.max(1, width);
393
505
  this.syncEditorFocus();
394
- if (this.mode === "edit" || this.mode === "add") return this.renderEditor(w);
506
+ if (this.mode !== "list") return this.renderEditor(w);
395
507
 
396
508
  const notes = this.notes();
397
509
  this.cursor.visible = fitRows(this.viewportRowsSource, notes.length, CHROME, SCREEN_DEFAULT_ROWS);
@@ -411,7 +523,19 @@ export class MemoryPickerComponent implements Component {
411
523
  ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
412
524
  : this.notice !== undefined
413
525
  ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
414
- : screenHint(this.theme, w, ["Esc close", "Enter/e edit", "a add", "d forget", "s/Tab scope", "↑/↓", "g/G"]);
526
+ : this.mergeSource
527
+ ? screenHint(this.theme, w, ["m merge into the focused note", "Esc cancel merge", "↑/↓"])
528
+ : screenHint(this.theme, w, [
529
+ "Esc close",
530
+ "Enter/e edit",
531
+ "a add",
532
+ "d forget",
533
+ "m merge",
534
+ "p move",
535
+ "s/Tab scope",
536
+ "↑/↓",
537
+ "g/G",
538
+ ]);
415
539
 
416
540
  return dockListFrame({
417
541
  header: titleRule(this.theme, w, "Memory"),
@@ -42,19 +42,28 @@ How the `write_plan` and `exit_plan_mode` calls and their results look in the tr
42
42
  `<repo-root>/.pi/plans/<YYYY-MM-DD-HHmm>-<slug>.md` (or `<agent-dir>/plans`
43
43
  outside a repository). The plans directory is self-ignoring, so plan files
44
44
  never show up as untracked files or in `rewind` snapshots.
45
+ - **A discoverable index.** `/plan list` walks the plans directory and shows
46
+ every plan with its title, modified time, and step progress, newest first — in
47
+ the TUI as a docked list where `Enter` opens the plan's review, elsewhere as a
48
+ notice with each plan's path. The directory stays the index: no registry file,
49
+ one bounded read per plan.
45
50
  - **A shape that reads.** `write_plan` documents a canonical outline — a
46
51
  `# <Title>`, then `## Goal`, `## Context`, `## Approach`, `## Steps`,
47
52
  `## Risks`, and `## Verification` — and the model is guided to follow it. This
48
53
  is guidance only: any markdown is accepted, stored, and rendered as written.
49
54
  - **Trackable steps.** Steps are written as `- [ ]` checkboxes in the `## Steps`
50
- section; approval seeds them into `todo`.
55
+ section; approval seeds them into `todo`, with the `## Verification` section as
56
+ the last item so the definition of done travels with the work. A plan with more
57
+ than 50 top-level steps is refused rather than truncated, so approval never
58
+ drops a step you reviewed.
51
59
  - **A navigable review.** The review screen splits the body at its headings,
52
60
  names the section you are in, and jumps between sections with `n`/`p`.
53
61
  - **What changed since the last review.** After a refinement, the review screen
54
62
  names how many sections changed, marks the current one in the scroll row, and
55
63
  the browser outline dots each changed section, so a re-review does not mean
56
- re-reading the whole plan. The baseline is what the previous review in this
57
- session showed; a resumed session starts without markers.
64
+ re-reading the whole plan. The baseline is one digest per heading section of
65
+ what the previous review showed, kept as a session entry, so a resumed session
66
+ or a `/tree` restore marks only what changed on its own branch.
58
67
  - **Kept current.** After approval, `set_plan_steps` flips the checkboxes of
59
68
  the steps you name in the file, so the plan mirrors what has actually been
60
69
  done. Pass the step's text as `expected` and an update whose step number moved
@@ -83,19 +92,28 @@ How the `write_plan` and `exit_plan_mode` calls and their results look in the tr
83
92
  nested sub-bullets are treated as notes. The list is a merge, not a
84
93
  replacement: existing items keep their place and status, a step whose text is
85
94
  already there is skipped, and the tool's item cap defers the rest instead of
86
- dropping anything. A plan without a `Steps` heading keeps the older rule — the
87
- shallowest list in the document — so prose bullets in a plan that follows the
88
- outline never become todos.
89
- - **Branch-aware.** The enabled flag is a custom session entry, so `/resume` and
90
- `/tree` restore the right mode.
95
+ dropping anything. The plan's `## Verification` section follows as the **last**
96
+ todo item, summarized on one line (`Verify: …`) — it is not a plan step, so it is
97
+ ticked in the todo list rather than with `set_plan_steps`, and it never counts
98
+ against the 50-step cap. A plan without a `Steps` heading keeps the older rule —
99
+ the shallowest list in the document — so prose bullets in a plan that follows the
100
+ outline never become todos, and its verification bullets are already covered.
101
+ - **Goal-aware.** While planning, the injected context restates an active
102
+ session goal as one bullet, so the plan is designed against the objective
103
+ instead of beside it. On approval, when the session has no goal at all, the user
104
+ is offered one confirm to record the plan's title as the goal — never replacing
105
+ an existing goal, and with no extra model call.
106
+ - **Branch-aware.** The enabled flag and the review baseline are custom session
107
+ entries, so `/resume` and `/tree` restore the right mode and keep the
108
+ changed-section markers.
91
109
 
92
110
  ## Tools
93
111
 
94
112
  | Tool | Active | Purpose |
95
113
  |---|---|---|
96
114
  | `enter_plan_mode` | normal mode | Ask to enter plan mode; empty schema, confirmed with `ctx.ui.confirm`. |
97
- | `write_plan` | plan mode | Save the plan: `title` (file-name slug), `content` (full markdown in the documented outline), optional `plan_path` to overwrite a refinement. Returns the absolute path. |
98
- | `exit_plan_mode` | plan mode | Read the plan file (`plan_path`) and present it for approval. |
115
+ | `write_plan` | plan mode | Save the plan: `title` (file-name slug), `content` (full markdown in the documented outline), optional `plan_path` to overwrite a refinement. Returns the absolute path. A plan with more than 50 top-level steps in its `Steps` section is refused, with the cap and the fix named, and nothing is written. |
116
+ | `exit_plan_mode` | plan mode | Read the plan file (`plan_path`) and present it for approval. Refuses a file whose step list is over the cap, before the review screen. |
99
117
  | `set_plan_steps` | normal mode | Keep an approved plan current: set the checkboxes of the given top-level steps (`updates`, each a 1-based `step` in the order `exit_plan_mode` listed, plus `status`, and optionally `expected` — the step's text, so a reordered plan is refused) to `pending` or `completed` in one write. Only the checkboxes change, and a reopened step drops its `[DONE:n]` marker. |
100
118
 
101
119
  ### Commands and flags
@@ -105,6 +123,7 @@ How the `write_plan` and `exit_plan_mode` calls and their results look in the tr
105
123
  | `enter_plan_mode` tool | the model, before a non-trivial task | `ctx.ui.confirm` |
106
124
  | `/plan` | the user | immediate |
107
125
  | `/plan <prompt>` | the user, with the task | immediate |
126
+ | `/plan list` | the user, browsing the plans directory | immediate |
108
127
  | `/plan review [path]` | the user, reviewing a plan in the browser | immediate |
109
128
  | `Ctrl+Alt+P` | the user | immediate |
110
129
  | `--plan` | the user, at launch | immediate |
@@ -143,7 +162,7 @@ actions (`a approve & run` in `success`, `r refine` in `accent`).
143
162
  mounted and opens an inline editor beneath it (`Enter` submits, an empty buffer
144
163
  does nothing, `Esc` returns to the plan); `Esc` keeps planning and tells the
145
164
  model the plan was not approved. When the plan changed since the previous review
146
- in this session, a summary line under the title names how many sections changed
165
+ on this branch, a summary line under the title names how many sections changed
147
166
  and the scroll row appends `· changed` to the current section. Scroll with `↑`/`↓` or `j`/`k`, `space`/`b` or
148
167
  `PgUp`/`PgDn`, `d`/`u`, `g`/`G`, and the mouse wheel. Press `n`/`p` to jump to
149
168
  the next or previous heading; the scroll row names the current section when the
@@ -321,7 +340,7 @@ file and returns them to the model.
321
340
  links, an active-section highlight, and a per-section comment count; the panel
322
341
  above it reports the `Steps` section's progress (`n/m`, the same list approval
323
342
  seeds) and total comments. A
324
- section that changed since the previous review in this session carries a dot
343
+ section that changed since the previous review on this branch carries a dot
325
344
  (its `title` and accessible label say so); an unchanged plan shows none.
326
345
  - **Inline threads.** A comment renders directly under the block it anchors to,
327
346
  labelled and indented as a thread, so it reads in place and the plan keeps one
@@ -386,14 +405,15 @@ file and returns them to the model.
386
405
  Git Bash (`bash`), which must be installed for any shell command to run.
387
406
  - The read-only guarantee is a guard rail against accidental writes, not a
388
407
  security boundary (see [Security](#security)).
389
- - The plans directory is the only index; there is no registry and no persisted
390
- plan metadata.
391
- - Changed markers need a prior review of the same plan in the same session. A
392
- resumed or new session has no baseline, so it shows no markers rather than
393
- marking the whole plan as changed.
394
- - Approval tracks at most 50 steps, and a step longer than 200 characters is
395
- shortened in the todo list and the approval listing. `write_plan` reports both
396
- cases when they apply; the plan file itself always keeps the full text.
408
+ - The plans directory is the only index. `/plan list` walks it on every open and
409
+ keeps no registry, so it sees an external edit or a hand-copied plan
410
+ immediately; it lists a file over the 256 KiB plan limit without reading it,
411
+ and does not index the agent directory's plans while a worktree is active.
412
+ - Approval tracks at most 50 steps: `write_plan` refuses a larger plan, and
413
+ `exit_plan_mode` refuses to present one, so no reviewed step is silently
414
+ dropped. A step longer than 200 characters is shortened in the todo list and
415
+ the approval listing — a display rule only, because the plan file always keeps
416
+ the full text.
397
417
  - Browser review needs a local browser: the server is loopback-only, so a remote
398
418
  turn cannot drive it.
399
419
  - A browser that never connects leaves the review waiting until the user
@@ -415,9 +435,9 @@ file and returns them to the model.
415
435
  | Integration point | How |
416
436
  |---|---|
417
437
  | 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`. |
418
- | Commands and flags | `/plan` (toggle, or enter and send a task), `/plan review [path]` (browser review), `--plan` launch flag, `Ctrl+Alt+P` shortcut. |
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. |
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. |
438
+ | Commands and flags | `/plan` (toggle, or enter and send a task), `/plan list` (plans index), `/plan review [path]` (browser review), `--plan` launch flag, `Ctrl+Alt+P` shortcut. |
439
+ | State | Enabled flag and the tools it hid persisted with `pi.appendEntry("plan-mode", { enabled, removed })`, and each review's baseline persisted with `pi.appendEntry("plan-baseline", { path, sections })` as one digest per heading section; both rebuilt from `ctx.sessionManager.getBranch()`, so `/resume` and `/tree` restore the mode and the changed markers of the branch they land on. Plan never writes a goal itself: an approval offer records one through `ctx.executeTool("goal", { objective })`. A review that shows the same content appends nothing. 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. |
440
+ | Hooks | `tool_call` blocks non-read-only calls and forces `subagent` read-only; `before_agent_start` injects the hidden `[PLAN MODE ACTIVE]` context, with an active session goal read from the branch through `lib/goal-state.ts`; `context` keeps only the newest plan context while enabled and drops stale ones otherwise. |
421
441
  | Lifecycle | `session_start`/`session_tree` restore state; `session_shutdown` clears the footer status and closes any browser review server. |
422
442
 
423
443
  ## Design notes
@@ -456,12 +476,21 @@ file and returns them to the model.
456
476
  channel; no import from that extension) and the steps are merged into them.
457
477
  A rejected seed reports the nested tool's own reason in the approval message
458
478
  instead of failing silently.
479
+ - **A cap is enforced where it can still be fixed.** Both refusal points —
480
+ `write_plan` and `exit_plan_mode` — sit before the write and before the review,
481
+ because those are the moments the model can still regroup the steps. A note on
482
+ a successful write would arrive after the file existed and the review had
483
+ already listed every step, which is exactly when the overflow becomes lost
484
+ work.
459
485
  - **Changed markers compare what was shown, not revisions on disk.** A review
460
- result is only meaningful next to the text the reviewer already read, so the
461
- runtime keeps the content each review presented (keyed by resolved path) and
462
- `changedSections` diffs section title and text against it. Stored feedback is
486
+ result is only meaningful next to the text the reviewer already read, so every
487
+ review marks the sections it presented (keyed by resolved path) and
488
+ `plan-text.ts` compares the next plan against those digests. Stored feedback is
463
489
  stripped from both sides, so a review comment never reads as an edit, and the
464
- same helper feeds the terminal summary and the browser outline.
490
+ same helper feeds the terminal summary and the browser outline. The digests are
491
+ a session entry rather than the previous text, so the markers survive a resume
492
+ without copying the plan file into the branch, and the reader and the writer
493
+ share one normalization so a tab cannot read as a change.
465
494
  - **The outline guides, it does not enforce.** `write_plan` documents the
466
495
  canonical shape in its description and stores whatever markdown it is given;
467
496
  no section is required and nothing is rewritten. That keeps the model free to
@@ -514,12 +543,14 @@ file and returns them to the model.
514
543
  | `git-bash.ts` | `checkReadOnlyGit`: the pure allowlist that restricts `bash` to read-only git commands. |
515
544
  | `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, atomic `write`/`setSteps`/`saveFeedback`, and `read`/`latest`. |
516
545
  | `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. |
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. |
518
- | `runtime.ts` | Enabled state, tool gating, persistence, control-tool identity, footer status, the review baseline. |
546
+ | `steps.ts` | Plan-document structure (pure): the outline, step extraction and the `Steps`-section scope, heading sections, and the checkbox edit. |
547
+ | `plan-text.ts` | The plan text a review shows and compares: `sanitizePlanText`, the one normalization both sides use, the per-section `sectionDigests`, and `changedSections`. |
548
+ | `runtime.ts` | Enabled state, tool gating, persistence (mode and review baseline), control-tool identity, footer status. |
519
549
  | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`: schemas, results, transcripts, and the approval-time todo seed. |
520
550
  | `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. |
521
551
  | `commands.ts` | `/plan` (enter, optional task) and the `/plan review [path]` browser subcommand. |
522
552
  | `tui.ts` | `PlanViewComponent` (sectioned Markdown body, changed-section summary and marker, section jump, scroll gauge, approve/refine/browser/keep). |
553
+ | `plan-index.ts` | `/plan list`: the notice text and the docked index screen (title, step progress, age, focused path; Enter reviews). |
523
554
  | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, tables, rules, blanks). |
524
555
  | `feedback.ts` | Browser-comment codec: `stripFeedback`, `parseFeedback`, `formatFeedback`, `feedbackSummary`, and payload validation. |
525
556
  | `web.ts` | `createPlanWebReview`: the loopback server, the section outline (with changed-section flags) and code highlighting, and the save/approve/cancel/abort session. |
@@ -2,22 +2,26 @@
2
2
  * Slash command for the plan-mode extension.
3
3
  *
4
4
  * `/plan` toggles plan mode, or enters it and sends an optional task;
5
- * `Ctrl+Alt+P` toggles too. `/plan review [path]` is the one reserved
6
- * subcommand: it opens the plan in the browser, and when the user saves their
7
- * comments sends them to the model as a refinement request.
5
+ * `Ctrl+Alt+P` toggles too. Two subcommands are reserved: `/plan review [path]`
6
+ * opens a plan in the browser (and sends saved comments to the model as a
7
+ * refinement), and `/plan list` shows the plans directory, newest first, with
8
+ * Enter opening a review.
8
9
  */
9
10
 
10
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
12
  import { isRemoteTurn } from "../../lib/interaction.ts";
12
13
  import { feedbackSummary } from "./feedback.ts";
14
+ import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
13
15
  import { READ_ONLY_SUMMARY } from "./policy.ts";
14
16
  import { runBrowserReview } from "./review.ts";
15
17
  import type { PlanRuntime } from "./runtime.ts";
18
+ import type { PlanSummary } from "./plans.ts";
16
19
  import { createPlanWebReview, type PlanWebReview } from "./web.ts";
17
20
 
18
21
  const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
19
22
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
20
23
  const REVIEW_PREFIX = "review";
24
+ const LIST_PREFIX = "list";
21
25
 
22
26
  /** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
23
27
  export function planModeNotice(enabled: boolean): string {
@@ -30,6 +34,14 @@ function reviewTarget(args: string): string | undefined {
30
34
  return target === "" ? undefined : target;
31
35
  }
32
36
 
37
+ /**
38
+ * Whether a command line is the reserved `list` subcommand. Exact match only, so
39
+ * `list the untracked files` stays a task instead of silently listing plans.
40
+ */
41
+ function isListSubcommand(prompt: string): boolean {
42
+ return prompt.toLowerCase() === LIST_PREFIX;
43
+ }
44
+
33
45
  /**
34
46
  * Whether a command line is the reserved `review` subcommand. Bare `review`
35
47
  * always is; a path after it is, but `review the auth flow` stays a task, so a
@@ -68,10 +80,10 @@ async function reviewInBrowser(
68
80
  return;
69
81
  }
70
82
 
71
- const previous = runtime.reviewed(file.path);
83
+ const baseline = runtime.reviewed(file.path);
72
84
  const result = await runBrowserReview(web, ctx.cwd, file, {
73
85
  canApprove: false,
74
- previous,
86
+ baseline,
75
87
  // Mark only once the page is actually open: a failed start must not consume
76
88
  // the baseline for a plan nobody saw.
77
89
  onOpened: (session) => {
@@ -100,6 +112,47 @@ async function reviewInBrowser(
100
112
  else await pi.sendUserMessage(message, { deliverAs: "followUp" });
101
113
  }
102
114
 
115
+ /**
116
+ * `/plan list`: show the plans directory, newest first. The TUI gets a docked
117
+ * list whose Enter opens the focused plan's review; every other mode gets the
118
+ * same facts as a notice, with each plan's path.
119
+ */
120
+ async function openPlanIndex(
121
+ pi: ExtensionAPI,
122
+ runtime: PlanRuntime,
123
+ web: PlanWebReview,
124
+ ctx: ExtensionContext,
125
+ ): Promise<void> {
126
+ const dir = await runtime.plans.dirFor(ctx.cwd);
127
+ const summarise = async (): Promise<Awaited<ReturnType<typeof runtime.plans.index>>> => runtime.plans.index(ctx.cwd);
128
+ const summaries = await summarise();
129
+
130
+ if (ctx.mode !== "tui") {
131
+ ctx.ui.notify(formatPlanIndex(summaries, dir), "info");
132
+ return;
133
+ }
134
+
135
+ // The review runs after the screen closes, so the input focus is free again.
136
+ let selected: (PlanSummary | undefined) | undefined;
137
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
138
+ const component = new PlanIndexComponent({
139
+ summaries,
140
+ dir,
141
+ theme,
142
+ onSelect: (summary) => {
143
+ selected = summary;
144
+ done();
145
+ },
146
+ onClose: () => done(),
147
+ reload: summarise,
148
+ requestRender: () => tui.requestRender(),
149
+ viewportRows: () => tui.terminal?.rows,
150
+ });
151
+ return component;
152
+ });
153
+ if (selected) await reviewInBrowser(pi, runtime, web, selected.path, ctx);
154
+ }
155
+
103
156
  export function registerCommands(
104
157
  pi: ExtensionAPI,
105
158
  runtime: PlanRuntime,
@@ -107,16 +160,22 @@ export function registerCommands(
107
160
  ): void {
108
161
  pi.registerCommand("plan", {
109
162
  description:
110
- "Toggle plan mode (read-only exploration), enter it with an optional task, or review a plan in the browser",
163
+ "Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, or review one in the browser",
111
164
  getArgumentCompletions: (prefix) => {
112
165
  const text = prefix.trimStart();
113
166
  if (text.includes(" ")) return null;
114
- return REVIEW_PREFIX.startsWith(text) ? [{ value: REVIEW_PREFIX, label: REVIEW_PREFIX }] : null;
167
+ const subcommands = [LIST_PREFIX, REVIEW_PREFIX].filter((name) => name.startsWith(text));
168
+ return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
115
169
  },
116
170
  handler: async (args, ctx) => {
117
171
  const prompt = args.trim();
118
172
 
119
- // `/plan review [path]` is a reserved subcommand; everything else is a task.
173
+ // `/plan list` and `/plan review [path]` are reserved subcommands;
174
+ // everything else is a task.
175
+ if (isListSubcommand(prompt)) {
176
+ await openPlanIndex(pi, runtime, web, ctx);
177
+ return;
178
+ }
120
179
  if (isReviewSubcommand(prompt)) {
121
180
  await reviewInBrowser(pi, runtime, web, reviewTarget(prompt), ctx);
122
181
  return;