@gavin-hu/my-pi-agent 0.9.0 → 0.10.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 (132) hide show
  1. package/README.md +28 -2
  2. package/README.zh-CN.md +27 -2
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/job.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/assets/question.png +0 -0
  8. package/assets/rewind.png +0 -0
  9. package/assets/todo.png +0 -0
  10. package/assets/tool-renderings.png +0 -0
  11. package/assets/tools/ask_user_question.png +0 -0
  12. package/assets/tools/bash.png +0 -0
  13. package/assets/tools/edit.png +0 -0
  14. package/assets/tools/enter_plan_mode.png +0 -0
  15. package/assets/tools/enter_worktree.png +0 -0
  16. package/assets/tools/exit_plan_mode.png +0 -0
  17. package/assets/tools/exit_worktree.png +0 -0
  18. package/assets/tools/find.png +0 -0
  19. package/assets/tools/goal.png +0 -0
  20. package/assets/tools/grep.png +0 -0
  21. package/assets/tools/job.png +0 -0
  22. package/assets/tools/list_worktrees.png +0 -0
  23. package/assets/tools/ls.png +0 -0
  24. package/assets/tools/memory.png +0 -0
  25. package/assets/tools/notice.png +0 -0
  26. package/assets/tools/open_path.png +0 -0
  27. package/assets/tools/prune_worktrees.png +0 -0
  28. package/assets/tools/read.png +0 -0
  29. package/assets/tools/read_doc.png +0 -0
  30. package/assets/tools/run_doctor.png +0 -0
  31. package/assets/tools/send_wechat.png +0 -0
  32. package/assets/tools/set_plan_steps.png +0 -0
  33. package/assets/tools/subagent.png +0 -0
  34. package/assets/tools/todo.png +0 -0
  35. package/assets/tools/turn-rule.png +0 -0
  36. package/assets/tools/user-message.png +0 -0
  37. package/assets/tools/watch.png +0 -0
  38. package/assets/tools/web_fetch.png +0 -0
  39. package/assets/tools/web_search.png +0 -0
  40. package/assets/tools/write.png +0 -0
  41. package/assets/tools/write_plan.png +0 -0
  42. package/extensions/bytefly/README.md +261 -36
  43. package/extensions/bytefly/block/component.ts +282 -0
  44. package/extensions/bytefly/block/config.ts +24 -0
  45. package/extensions/bytefly/block/fallback.ts +120 -0
  46. package/extensions/bytefly/block/register.ts +164 -0
  47. package/extensions/bytefly/chrome.ts +97 -0
  48. package/extensions/bytefly/doctor/tools.ts +5 -2
  49. package/extensions/bytefly/header/component.ts +21 -10
  50. package/extensions/bytefly/header/config.ts +0 -3
  51. package/extensions/bytefly/header/register.ts +6 -1
  52. package/extensions/bytefly/index.ts +32 -12
  53. package/extensions/bytefly/message/config.ts +15 -0
  54. package/extensions/bytefly/message/format.ts +42 -0
  55. package/extensions/bytefly/message/register.ts +15 -0
  56. package/extensions/bytefly/notice/component.ts +107 -0
  57. package/extensions/bytefly/notice/config.ts +41 -0
  58. package/extensions/bytefly/notice/register.ts +27 -0
  59. package/extensions/bytefly/pad.ts +42 -0
  60. package/extensions/bytefly/separator/component.ts +2 -1
  61. package/extensions/bytefly/separator/format.ts +17 -10
  62. package/extensions/bytefly/separator/register.ts +3 -2
  63. package/extensions/bytefly/working/config.ts +20 -0
  64. package/extensions/bytefly/working/format.ts +25 -0
  65. package/extensions/bytefly/working/register.ts +88 -0
  66. package/extensions/document/README.md +11 -4
  67. package/extensions/document/paging.ts +10 -10
  68. package/extensions/document/render.ts +7 -7
  69. package/extensions/file-browse/README.md +3 -1
  70. package/extensions/file-browse/client.ts +9 -2
  71. package/extensions/file-browse/git.ts +4 -7
  72. package/extensions/file-browse/html.ts +27 -17
  73. package/extensions/file-open/README.md +6 -0
  74. package/extensions/goal/README.md +16 -10
  75. package/extensions/goal/format.ts +7 -20
  76. package/extensions/goal/tools.ts +5 -17
  77. package/extensions/goal/tui.ts +12 -40
  78. package/extensions/job/README.md +21 -12
  79. package/extensions/job/format.ts +2 -2
  80. package/extensions/job/index.ts +25 -20
  81. package/extensions/job/tools.ts +5 -3
  82. package/extensions/job/tui.ts +13 -8
  83. package/extensions/memory/README.md +6 -0
  84. package/extensions/memory/format.ts +5 -3
  85. package/extensions/plan/README.md +32 -12
  86. package/extensions/plan/tools.ts +20 -5
  87. package/extensions/plan/web-client.ts +58 -17
  88. package/extensions/plan/web-page.ts +34 -9
  89. package/extensions/plan/web-router.ts +40 -9
  90. package/extensions/plan/web.ts +22 -2
  91. package/extensions/question/README.md +13 -1
  92. package/extensions/question/answers.ts +25 -18
  93. package/extensions/question/tools.ts +11 -6
  94. package/extensions/rewind/README.md +2 -0
  95. package/extensions/rewind/format.ts +6 -1
  96. package/extensions/rewind/index.ts +3 -1
  97. package/extensions/rewind/snapshot-run.ts +1 -1
  98. package/extensions/subagent/README.md +6 -0
  99. package/extensions/subagent/index.ts +1 -1
  100. package/extensions/subagent/render.ts +56 -29
  101. package/extensions/todo/README.md +19 -12
  102. package/extensions/todo/format.ts +17 -25
  103. package/extensions/todo/tools.ts +2 -1
  104. package/extensions/todo/tui.ts +3 -2
  105. package/extensions/watch/README.md +14 -6
  106. package/extensions/watch/format.ts +2 -2
  107. package/extensions/watch/index.ts +28 -23
  108. package/extensions/watch/tools.ts +5 -3
  109. package/extensions/watch/tui.ts +12 -7
  110. package/extensions/web-access/README.md +16 -7
  111. package/extensions/web-access/fetch/tool.ts +1 -1
  112. package/extensions/web-access/search/tool.ts +3 -3
  113. package/extensions/wechat/README.md +6 -0
  114. package/extensions/worktree/README.md +6 -0
  115. package/lib/README.md +2 -1
  116. package/lib/format.ts +17 -0
  117. package/lib/notice.ts +87 -0
  118. package/lib/tui/chrome.ts +8 -4
  119. package/lib/tui/cursor.ts +2 -1
  120. package/lib/tui/glyphs.ts +10 -11
  121. package/lib/tui/indent.ts +166 -0
  122. package/lib/tui/index.ts +3 -1
  123. package/lib/tui/rows.ts +39 -5
  124. package/lib/tui/rules.ts +9 -3
  125. package/lib/tui/tokens.ts +14 -0
  126. package/lib/web/base.ts +2 -1
  127. package/lib/web/components.ts +3 -1
  128. package/lib/web/layout.ts +5 -1
  129. package/package.json +7 -3
  130. package/themes/bytefly-dark.json +7 -8
  131. package/themes/bytefly-light.json +10 -11
  132. package/assets/bytefly-chrome.png +0 -0
@@ -25,6 +25,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
25
25
  var token = typeof data.token === "string" ? data.token : "";
26
26
  var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
27
27
  var saved = false;
28
+ var approved = false;
28
29
  var closed = false;
29
30
  var connected = null;
30
31
  var everConnected = false;
@@ -37,6 +38,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
37
38
  var planEl = document.getElementById("plan");
38
39
  var statusEl = document.getElementById("status");
39
40
  var stateEl = document.getElementById("review-state");
41
+ var approveButton = document.getElementById("approve");
40
42
  var saveButton = document.getElementById("save");
41
43
  var cancelButton = document.getElementById("cancel");
42
44
  var outlineList = document.getElementById("outline-list");
@@ -62,9 +64,15 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
62
64
  if (!stateEl) return;
63
65
  var label;
64
66
  var tone = "";
65
- if (saved) {
67
+ if (approved) {
68
+ label = "Approved";
69
+ tone = "success";
70
+ } else if (saved) {
66
71
  label = "Saved";
67
72
  tone = "success";
73
+ } else if (closed) {
74
+ // Settled by the user, not by a dropped stream: never flash Offline.
75
+ label = "Closed";
68
76
  } else if (connected === true) {
69
77
  label = "Connected";
70
78
  tone = "success";
@@ -78,7 +86,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
78
86
  }
79
87
  var count = annotations.length;
80
88
  label += " \u00b7 " + count + (count === 1 ? " comment" : " comments");
81
- if (dirty && !saved) {
89
+ if (dirty && !saved && !approved && !closed) {
82
90
  label += " \u00b7 unsaved";
83
91
  if (!tone) tone = "warn";
84
92
  }
@@ -254,9 +262,14 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
254
262
  return;
255
263
  }
256
264
  if (block.kind === "list") {
265
+ var items = [];
257
266
  lines.forEach(function (line) {
258
267
  var match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
259
268
  if (!match) {
269
+ // A following unmarked line is a CommonMark lazy continuation of the
270
+ // item above; join it so the paragraph hangs under the item's text.
271
+ var last = items[items.length - 1];
272
+ if (last) { if (line.trim()) last.rest += " " + line.trim(); return; }
260
273
  var plain = el("p"); renderInline(plain, line); wrap.appendChild(plain); return;
261
274
  }
262
275
  var row = el("div", "bf-block__li");
@@ -272,9 +285,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
272
285
  rest = box[2];
273
286
  }
274
287
  marker.textContent = markerText;
275
- var body = el("span"); renderInline(body, rest);
288
+ var body = el("span");
276
289
  row.appendChild(marker); row.appendChild(body); wrap.appendChild(row);
290
+ items.push({ body: body, rest: rest });
277
291
  });
292
+ // Render each body once, after its continuation lines are collected.
293
+ items.forEach(function (item) { renderInline(item.body, item.rest); });
278
294
  return;
279
295
  }
280
296
  var paragraph = el("p");
@@ -600,6 +616,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
600
616
  }
601
617
 
602
618
  function lockActions() {
619
+ if (approveButton) approveButton.disabled = true;
603
620
  if (saveButton) saveButton.disabled = true;
604
621
  if (cancelButton) cancelButton.disabled = true;
605
622
  }
@@ -618,51 +635,67 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
618
635
  controls.appendChild(reload);
619
636
  }
620
637
 
621
- function save() {
622
- if (saved) return;
623
- setStatus("Saving\u2026");
624
- fetch("/api/feedback?token=" + encodeURIComponent(token), {
638
+ // Save and approve share the same POST shape; only the route, the settled
639
+ // flag, and the wording differ. Both persist the current annotations.
640
+ function submit(url, okText, failText, mark) {
641
+ fetch(url + "?token=" + encodeURIComponent(token), {
625
642
  method: "POST",
626
643
  headers: { "Content-Type": "application/json" },
627
644
  body: JSON.stringify({ annotations: annotations })
628
645
  }).then(function (response) {
629
646
  if (response.ok) {
630
- saved = true;
647
+ mark();
631
648
  dirty = false;
632
- setStatus("Saved \u2014 return to Pi to continue.", "saved");
649
+ setStatus(okText, "saved");
633
650
  lockActions();
634
651
  updateState();
635
652
  return;
636
653
  }
637
654
  if (response.status === 409) {
638
655
  setStatus("The plan changed on disk \u2014 reload to review again.", "error");
656
+ if (approveButton) approveButton.disabled = true;
639
657
  if (saveButton) saveButton.disabled = true;
640
658
  offerReload();
641
659
  return;
642
660
  }
643
661
  throw new Error("server returned " + response.status);
644
662
  }).catch(function (error) {
645
- setStatus("Could not save: " + error.message, "error");
663
+ setStatus(failText + error.message, "error");
646
664
  });
647
665
  }
648
666
 
667
+ function save() {
668
+ if (saved || approved || closed) return;
669
+ setStatus("Saving\u2026");
670
+ submit("/api/feedback", "Saved \u2014 return to Pi to continue.", "Could not save: ", function () { saved = true; });
671
+ }
672
+
673
+ function approve() {
674
+ if (saved || approved || closed) return;
675
+ setStatus("Approving\u2026");
676
+ submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () { approved = true; });
677
+ }
678
+
649
679
  function cancel() {
650
- if (saved || closed) return;
680
+ if (saved || approved || closed) return;
651
681
  closed = true;
652
682
  lockActions();
653
683
  setStatus("Closing\u2026");
684
+ updateState();
654
685
  fetch("/api/cancel?token=" + encodeURIComponent(token), { method: "POST" }).catch(function () {}).then(function () {
655
- setStatus("Closed without saving.");
686
+ setStatus("Closed \u2014 return to Pi.");
687
+ updateState();
656
688
  });
657
689
  }
658
690
 
691
+ if (approveButton) approveButton.addEventListener("click", approve);
659
692
  if (saveButton) saveButton.addEventListener("click", save);
660
693
  if (cancelButton) cancelButton.addEventListener("click", cancel);
661
694
 
662
695
  // Remember a selection made inside a block so clicking the rail button can
663
696
  // still quote it after the button press has collapsed the selection.
664
697
  document.addEventListener("mouseup", function (event) {
665
- if (!planEl || saved) return;
698
+ if (!planEl || saved || approved || closed) return;
666
699
  var wrap = event.target && event.target.closest ? event.target.closest(".bf-block") : null;
667
700
  var quote = wrap && !wrap.querySelector(".bf-composer") ? selectionIn(wrap) : "";
668
701
  pendingQuote = wrap && quote ? { wrap: wrap, quote: quote } : null;
@@ -692,7 +725,13 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
692
725
  }
693
726
 
694
727
  document.addEventListener("keydown", function (event) {
695
- if (event.key === "Escape") { closeComposer(); toggleKeys(false); return; }
728
+ if (event.key === "Escape") {
729
+ // Esc peels one layer at a time: the composer, then the sheet, then the review.
730
+ if (planEl && planEl.querySelector(".bf-composer")) { closeComposer(); return; }
731
+ if (keysDialog && !keysDialog.hidden) { toggleKeys(false); return; }
732
+ cancel();
733
+ return;
734
+ }
696
735
  if (typing(event.target)) return;
697
736
  if (event.metaKey || event.ctrlKey || event.altKey) return;
698
737
  if (event.key === "?") { event.preventDefault(); toggleKeys(); return; }
@@ -701,11 +740,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
701
740
  if (event.key === "j") { event.preventDefault(); moveActive(1); return; }
702
741
  if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
703
742
  if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
743
+ if (event.key === "a" && approveButton) { event.preventDefault(); approve(); return; }
704
744
  if (event.key === "s") { event.preventDefault(); save(); return; }
705
745
  });
706
746
 
707
747
  window.addEventListener("beforeunload", function () {
708
- if (saved || closed) return;
748
+ if (saved || approved || closed) return;
709
749
  var url = "/api/cancel?token=" + encodeURIComponent(token);
710
750
  if (navigator.sendBeacon) navigator.sendBeacon(url);
711
751
  });
@@ -718,8 +758,9 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
718
758
  var stream = new EventSource("/events?token=" + encodeURIComponent(token));
719
759
  stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
720
760
  stream.addEventListener("error", function () {
721
- // The server ends the stream on save; keep showing Saved rather than Offline.
722
- if (!saved) { connected = false; updateState(); }
761
+ // The server ends the stream once the review is settled; keep showing the
762
+ // terminal state rather than Offline.
763
+ if (!saved && !approved && !closed) { connected = false; updateState(); }
723
764
  });
724
765
  } catch (error) {
725
766
  // The server still sees the POST; the stream is only for close detection.
@@ -49,6 +49,8 @@ export interface ReviewPageData {
49
49
  outline: ReviewOutlineItem[];
50
50
  /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
51
51
  code: Record<string, string[]>;
52
+ /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
53
+ canApprove: boolean;
52
54
  /** Per-session secret the client echoes on every request. */
53
55
  token: string;
54
56
  }
@@ -116,7 +118,7 @@ ${WEB_COMPONENTS_CSS}
116
118
  .bf-block__li{display:flex;gap:8px;margin:4px 0;line-height:var(--lh-relaxed)}
117
119
  .bf-block__marker{flex:0 0 auto;min-width:1.4em;color:var(--muted)}
118
120
  .bf-block__marker--done{color:var(--success)}
119
- .bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:.1em .35em}
121
+ .bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border-radius:var(--radius-sm);padding:.1em .35em}
120
122
  .bf-block pre.bf-code{margin:11px 0;padding:var(--space-3) 0;overflow:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);font:var(--fs-code)/1.55 var(--font-mono);tab-size:4;flex:none}
121
123
  .bf-block pre.bf-code code{background:none;border:0;padding:0;font-size:inherit}
122
124
  .bf-block pre.bf-code .bf-code__line{display:block;padding:0 14px;white-space:pre;min-height:1.55em}
@@ -170,20 +172,36 @@ ${WEB_COMPONENTS_CSS}
170
172
  @media (hover:none){.bf-block__add{opacity:1}}
171
173
  `.trim();
172
174
 
175
+ /**
176
+ * The sidebar's one-line keyboard reminder. It names the keys a reviewer reaches
177
+ * for first and defers to the shortcut sheet (`?`) for the rest, so the line does
178
+ * not have to grow every time a binding is added.
179
+ */
180
+ function reviewHints(canApprove: boolean): string {
181
+ const approve = canApprove ? `, <kbd class="review-kbd">a</kbd> to approve` : "";
182
+ return `<p class="review-hints">Press <kbd class="review-kbd">j</kbd>/<kbd class="review-kbd">k</kbd> to move, <kbd class="review-kbd">c</kbd> to comment${approve}, <kbd class="review-kbd">s</kbd> to save, <kbd class="review-kbd">?</kbd> for all shortcuts.</p>`;
183
+ }
184
+
173
185
  /** The keyboard sheet, hidden until the client opens it. */
174
- const KEYS_DIALOG = `<div class="bf-dialog" id="keys-dialog" hidden>
186
+ function keysDialog(canApprove: boolean): string {
187
+ const approveRow = canApprove
188
+ ? `<div><dt><kbd class="review-kbd">a</kbd></dt><dd>Approve and run the plan</dd></div>
189
+ `
190
+ : "";
191
+ return `<div class="bf-dialog" id="keys-dialog" hidden>
175
192
  <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
176
193
  <div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
177
194
  <dl class="review-keys">
178
- <div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
195
+ ${approveRow}<div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
179
196
  <div><dt><kbd class="review-kbd">c</kbd></dt><dd>Comment on the current block</dd></div>
180
197
  <div><dt><kbd class="review-kbd">Enter</kbd></dt><dd>Add the comment you are writing</dd></div>
181
198
  <div><dt><kbd class="review-kbd">s</kbd></dt><dd>Save feedback and return to Pi</dd></div>
182
- <div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer or this sheet</dd></div>
199
+ <div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer, the sheet, or the review</dd></div>
183
200
  <div><dt><kbd class="review-kbd">?</kbd></dt><dd>Toggle this sheet</dd></div>
184
201
  </dl>
185
202
  </div>
186
203
  </div>`;
204
+ }
187
205
 
188
206
  /**
189
207
  * Render the review page for `data`. The markup is a fixed shell; the client
@@ -200,11 +218,18 @@ export function renderReviewPage(data: ReviewPageData): string {
200
218
  <div class="bf-panel review-panel">
201
219
  ${reviewFacts()}
202
220
  <nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
203
- <p class="review-hints">Press <kbd class="review-kbd">j</kbd>/<kbd class="review-kbd">k</kbd> to move, <kbd class="review-kbd">c</kbd> to comment, <kbd class="review-kbd">s</kbd> to save.</p>
221
+ ${reviewHints(data.canApprove)}
204
222
  </div>
205
223
  </details>
206
224
  `
207
225
  : "";
226
+ // Approve is the decisive action, so it takes the primary button and Save is
227
+ // demoted; a comment-only review has no approve button and keeps Save primary.
228
+ const approveButton = data.canApprove
229
+ ? `
230
+ <button type="button" class="bf-btn bf-btn--primary" id="approve">Approve &amp; run</button>`
231
+ : "";
232
+ const saveButton = `<button type="button" class="${data.canApprove ? "bf-btn" : "bf-btn bf-btn--primary"}" id="save">Save feedback</button>`;
208
233
  return `<!doctype html>
209
234
  <html lang="en">
210
235
  <head>
@@ -221,15 +246,15 @@ ${brandLockup("plan review")}
221
246
  <span class="bf-status review-status" id="status" role="status" aria-live="polite"></span>
222
247
  <div class="review-controls">
223
248
  ${themeToggle()}
224
- <button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
225
- <button type="button" class="bf-btn bf-btn--primary" id="save">Save feedback</button>
226
- <button type="button" class="bf-btn" id="cancel">Close</button>
249
+ <button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>${approveButton}
250
+ ${saveButton}
251
+ <button type="button" class="bf-btn" id="cancel">Back to Pi</button>
227
252
  </div>
228
253
  </div><div class="review-progress" aria-hidden="true"><span class="review-progress__fill" id="progress-fill"></span></div></header>
229
254
  <div class="${bodyClass}">
230
255
  ${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
231
256
  </div>
232
- ${KEYS_DIALOG}
257
+ ${keysDialog(data.canApprove)}
233
258
  <script type="application/json" id="review-data">${island}</script>
234
259
  <script src="/client.js" defer></script>
235
260
  </body>
@@ -12,9 +12,9 @@
12
12
  * local page from driving the session.
13
13
  */
14
14
 
15
- import { REVIEW_CLIENT_JS } from "./web-client.ts";
16
15
  import { THEME_JS } from "../../lib/web/index.ts";
17
16
  import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
17
+ import { REVIEW_CLIENT_JS } from "./web-client.ts";
18
18
  import { renderReviewPage, type ReviewPageData } from "./web-page.ts";
19
19
 
20
20
  /** What a save callback reports back. */
@@ -24,12 +24,16 @@ export type SaveResult = { ok: true } | { ok: false; status: number; message: st
24
24
  export interface ReviewContext {
25
25
  /** Per-session secret required on the page, event stream, and API routes. */
26
26
  token: string;
27
+ /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
28
+ canApprove: boolean;
27
29
  /** Build the page data (recomputed per request from the current plan). */
28
30
  page: () => Promise<ReviewPageData>;
29
31
  /** Persist the annotations and report the outcome. */
30
32
  save: (annotations: PlanAnnotation[]) => Promise<SaveResult>;
31
33
  /** Called after a successful save; the server resolves the session once the response has flushed. */
32
34
  onSaved: (annotations: PlanAnnotation[]) => void;
35
+ /** Called after a successful approve; the server resolves the session once the response has flushed. */
36
+ onApprove: (annotations: PlanAnnotation[]) => void;
33
37
  /** Called when the user cancels; the server resolves the session once the response has flushed. */
34
38
  onCancel: () => void;
35
39
  }
@@ -80,6 +84,27 @@ function errorResponse(status: number, message: string): ServeResponse {
80
84
  return jsonResponse(status, { error: message });
81
85
  }
82
86
 
87
+ /**
88
+ * Parse and validate an `{ annotations }` request body. Returns the annotations,
89
+ * or an error response to send instead.
90
+ */
91
+ function parseAnnotations(body: string): PlanAnnotation[] | ServeResponse {
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(body);
95
+ } catch {
96
+ return errorResponse(400, "The request body must be JSON.");
97
+ }
98
+ const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
99
+ if (!annotations) return errorResponse(400, "The annotations are malformed.");
100
+ return annotations;
101
+ }
102
+
103
+ /** A parsed body is either the annotations or the error response to send. */
104
+ function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResponse {
105
+ return !Array.isArray(value);
106
+ }
107
+
83
108
  /** Validate method and `Host`; returns an error response to send instead. */
84
109
  function checkEnvelope(
85
110
  method: string,
@@ -139,19 +164,25 @@ export async function handleReviewRequest(
139
164
  }
140
165
  if (pathname === "/api/feedback") {
141
166
  if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to save feedback.");
142
- let parsed: unknown;
143
- try {
144
- parsed = JSON.parse(body);
145
- } catch {
146
- return errorResponse(400, "The request body must be JSON.");
147
- }
148
- const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
149
- if (!annotations) return errorResponse(400, "The annotations are malformed.");
167
+ const annotations = parseAnnotations(body);
168
+ if (isResponse(annotations)) return annotations;
150
169
  const result = await ctx.save(annotations);
151
170
  if (!result.ok) return errorResponse(result.status, result.message);
152
171
  ctx.onSaved(annotations);
153
172
  return jsonResponse(200, { ok: true });
154
173
  }
174
+ if (pathname === "/api/approve") {
175
+ if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to approve.");
176
+ // Only the exit_plan_mode flow owns the approval side effects, so a comment-only
177
+ // review refuses this even if a client posts it directly.
178
+ if (!ctx.canApprove) return errorResponse(403, "This review cannot be approved from the browser.");
179
+ const annotations = parseAnnotations(body);
180
+ if (isResponse(annotations)) return annotations;
181
+ const result = await ctx.save(annotations);
182
+ if (!result.ok) return errorResponse(result.status, result.message);
183
+ ctx.onApprove(annotations);
184
+ return jsonResponse(200, { ok: true });
185
+ }
155
186
 
156
187
  return errorResponse(404, "That page does not exist.");
157
188
  }
@@ -24,6 +24,7 @@ import type { ReviewPageData } from "./web-page.ts";
24
24
  /** How a browser review ended. */
25
25
  export type PlanReviewResult =
26
26
  | { status: "saved"; plan: StoredPlan; annotations: PlanAnnotation[] }
27
+ | { status: "approved"; plan: StoredPlan; annotations: PlanAnnotation[] }
27
28
  | { status: "cancelled" };
28
29
 
29
30
  /** A running review the caller awaits and then closes. */
@@ -38,9 +39,17 @@ export interface PlanReviewSession {
38
39
  close(): Promise<void>;
39
40
  }
40
41
 
42
+ /** Per-session options for {@link PlanWebReview.start}. */
43
+ export interface PlanReviewOptions {
44
+ /** Abort signal; aborting closes the session as cancelled. */
45
+ signal?: AbortSignal;
46
+ /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
47
+ canApprove?: boolean;
48
+ }
49
+
41
50
  /** Starts and tracks review sessions. */
42
51
  export interface PlanWebReview {
43
- start(cwd: string, plan: StoredPlan, signal?: AbortSignal): Promise<PlanReviewSession | undefined>;
52
+ start(cwd: string, plan: StoredPlan, options?: PlanReviewOptions): Promise<PlanReviewSession | undefined>;
44
53
  /** Stop every running session (session shutdown). */
45
54
  closeAll(): Promise<void>;
46
55
  }
@@ -96,7 +105,13 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
96
105
  // review turns (and its saved browser theme with it).
97
106
  const ports = new Map<string, number>();
98
107
 
99
- const start = async (cwd: string, plan: StoredPlan, signal?: AbortSignal): Promise<PlanReviewSession | undefined> => {
108
+ const start = async (
109
+ cwd: string,
110
+ plan: StoredPlan,
111
+ options: PlanReviewOptions = {},
112
+ ): Promise<PlanReviewSession | undefined> => {
113
+ const { signal } = options;
114
+ const canApprove = options.canApprove === true;
100
115
  const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
101
116
  let settled = false;
102
117
  let resolveDone!: (result: PlanReviewResult) => void;
@@ -147,6 +162,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
147
162
  })),
148
163
  code,
149
164
  token,
165
+ canApprove,
150
166
  };
151
167
  };
152
168
 
@@ -166,11 +182,15 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
166
182
 
167
183
  const context: ReviewContext = {
168
184
  token,
185
+ canApprove,
169
186
  page,
170
187
  save,
171
188
  onSaved: (annotations) => {
172
189
  pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
173
190
  },
191
+ onApprove: (annotations) => {
192
+ pendingResult = savedPlan ? { status: "approved", plan: savedPlan, annotations } : { status: "cancelled" };
193
+ },
174
194
  onCancel: () => {
175
195
  pendingResult = { status: "cancelled" };
176
196
  },
@@ -4,12 +4,20 @@ A single model-facing tool, `ask_user_question`, that lets the model ask the
4
4
  human one or more structured questions mid-turn and wait for typed answers.
5
5
  Modelled on Claude Code's `AskUserQuestion`.
6
6
 
7
+ <img src="../../assets/question.png" alt="The ask_user_question dialog: Auth and Scope tabs, a question, four numbered options with descriptions, and the navigation hint" width="100%">
8
+
7
9
  ```
8
10
  pi --extension ./extensions/question # load just this extension
9
11
  pi -e . # load the whole @gavin-hu/my-pi-agent package
10
12
  pi install ./ # install the package
11
13
  ```
12
14
 
15
+ ## Tool rendering
16
+
17
+ How the `ask_user_question` call and its result look in the transcript — the call line as a headline and the body indented under it:
18
+
19
+ <img src="../../assets/tools/ask_user_question.png" alt="The ask_user_question tool's call and result: the call line and an indented body with the question headers, the options, and the answers" width="100%">
20
+
13
21
  ## What it does
14
22
 
15
23
  The model calls `ask_user_question` with 1–4 questions. Each question offers up
@@ -162,7 +170,11 @@ None in v1 — the tool is either available (a UI exists) or not.
162
170
  - **Transcript rendering is driven by `details`.** `renderCall` summarises the
163
171
  question count and headers and lists one option line per question;
164
172
  `renderResult` renders `✓ Auth: 1. OAuth` per answer, `(wrote)` for custom
165
- text, warning-colored `Cancelled`, and dim `No UI` when unavailable.
173
+ text, warning-colored `Cancelled`, and dim `No UI` when unavailable. The call
174
+ summary reads the call's *streaming* args, so a field that has not arrived yet
175
+ — a question `header`, an option `label` — reads as absent instead of
176
+ throwing; a throwing renderer makes Pi fall back to its own call line, a
177
+ column to the left of the framed one.
166
178
 
167
179
  ## Files
168
180
 
@@ -80,37 +80,44 @@ export function formatAnswerText(result: AskResult): string {
80
80
  return lines.join("\n");
81
81
  }
82
82
 
83
- /** Minimal shape needed to summarize a call, before or after normalization. */
83
+ /**
84
+ * Minimal shape needed to summarize a call, before or after normalization.
85
+ *
86
+ * The transcript renderer reads this from a call's *streaming* args, where a
87
+ * field can be missing or still be a fragment: a partial parse can hand back an
88
+ * option before its label has arrived. Every field is therefore `unknown`, and
89
+ * the helpers below read a non-string as absent rather than throw.
90
+ */
84
91
  export interface CallQuestion {
85
- header?: string;
86
- options?: { label: string }[];
92
+ header?: unknown;
93
+ options?: unknown;
94
+ }
95
+
96
+ /** A partial value read as a single line; anything but a string reads empty. */
97
+ function asLine(value: unknown): string {
98
+ return typeof value === "string" ? sanitizeLine(value) : "";
87
99
  }
88
100
 
89
101
  /** Display headers with positional defaults (`Q1`, `Q2`, …). */
90
102
  export function questionHeaders(questions: CallQuestion[]): string[] {
91
- return questions.map((question, i) => sanitizeLine(question?.header?.trim() || `Q${i + 1}`));
103
+ return questions.map((question, i) => asLine(question?.header) || `Q${i + 1}`);
92
104
  }
93
105
 
94
106
  /** One `Header: a, b, Other` summary per question that offers options. */
95
107
  export function optionSummary(questions: CallQuestion[]): string[] {
96
108
  return questions
97
- .map((question, i) => ({ question, header: question?.header?.trim() || `Q${i + 1}` }))
98
- .filter(({ question }) => (question?.options?.length ?? 0) > 0)
99
- .map(({ question, header }) => {
100
- const labels = question.options?.map((option) => sanitizeLine(option.label)).join(", ");
101
- return `${sanitizeLine(header)}: ${labels}, Other`;
109
+ .map((question, i) => ({ header: asLine(question?.header) || `Q${i + 1}`, options: question?.options }))
110
+ .filter(({ options }) => Array.isArray(options) && options.length > 0)
111
+ .map(({ header, options }) => {
112
+ // A partial option has no label yet; list the ones that have arrived.
113
+ const labels = (options as unknown[])
114
+ .map((option) => (option as { label?: unknown } | undefined)?.label)
115
+ .filter((label): label is string => typeof label === "string")
116
+ .map((label) => sanitizeLine(label));
117
+ return `${header}: ${labels.length > 0 ? `${labels.join(", ")}, Other` : "Other"}`;
102
118
  });
103
119
  }
104
120
 
105
- /** One-line summary of the questions, used by the transcript call renderer. */
106
- export function formatCallText(questions: CallQuestion[]): string {
107
- const noun = questions.length === 1 ? "question" : "questions";
108
- const lines = [`ask_user_question ${questions.length} ${noun} (${questionHeaders(questions).join(", ")})`];
109
- const options = optionSummary(questions);
110
- if (options.length > 0) lines.push(` Options: ${options.join(" · ")}`);
111
- return lines.join("\n");
112
- }
113
-
114
121
  /** Human-readable summary of a selection, used in the Submit tab. */
115
122
  export function describeSelection(selection: Selection | undefined): string {
116
123
  if (!selection) return "(unanswered)";
@@ -70,16 +70,21 @@ export function registerTools(pi: ExtensionAPI): void {
70
70
  },
71
71
 
72
72
  renderCall(args, theme, context) {
73
- const raw = (args as Partial<AskUserQuestionArgs>).questions ?? [];
74
- const count = raw.length;
73
+ // The args stream in: `questions` can be absent, and an option can arrive
74
+ // before its label. Read a partial call as a smaller summary and never
75
+ // throw — a throwing renderer makes Pi fall back to its own call line.
76
+ const raw = (args as Partial<AskUserQuestionArgs> | undefined)?.questions;
77
+ const questions = Array.isArray(raw) ? raw : [];
78
+ const count = questions.length;
75
79
  const noun = count === 1 ? "question" : "questions";
76
- const headers = questionHeaders(raw);
80
+ const headers = questionHeaders(questions);
77
81
  let text = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", `${count} ${noun}`);
78
82
  if (headers.length > 0) text += paint(theme, "faint", ` (${headers.join(", ")})`);
79
83
 
80
- // One indented line per question that offers options, so a long option list
81
- // stays mapped to its question instead of wrapping on a single row.
82
- for (const line of optionSummary(raw)) text += `\n${paint(theme, "faint", ` ${line}`)}`;
84
+ // One line per question that offers options, so a long option list stays
85
+ // mapped to its question instead of wrapping on a single row. The line
86
+ // carries no indent of its own; the tool-block frame supplies it.
87
+ for (const line of optionSummary(questions)) text += `\n${paint(theme, "meta", line)}`;
83
88
 
84
89
  const component = reuseText(context?.lastComponent);
85
90
  component.setText(text);
@@ -10,6 +10,8 @@ rewind uses Pi's session tree, so the branch you leave is not erased.
10
10
  This is the `rewind` extension entrypoint; it is one of two sibling git
11
11
  extensions in this package (`worktree` is the other).
12
12
 
13
+ <img src="../../assets/rewind.png" alt="The /rewind screen: prompt rows newest first, a diamond marking prompts with a code snapshot, and the focused prompt's snapshot detail" width="100%">
14
+
13
15
  ```
14
16
  pi --extension ./extensions/rewind # load this extension alone
15
17
  pi -e . # load the whole @gavin-hu/my-pi-agent package
@@ -28,8 +28,13 @@ function truncate(text: string, max = LABEL_WIDTH): string {
28
28
  /**
29
29
  * A short, single-line summary of a user prompt for storage and display.
30
30
  * Takes the first non-empty line, collapses whitespace, and truncates.
31
+ *
32
+ * A run started by a custom message (a wake) has no user prompt: `event.prompt`
33
+ * is typed `string` but is `undefined` there, so a missing prompt is accepted
34
+ * and yields no summary rather than throwing.
31
35
  */
32
- export function summarizePrompt(text: string): string | undefined {
36
+ export function summarizePrompt(text: string | undefined): string | undefined {
37
+ if (typeof text !== "string") return undefined;
33
38
  const line = text
34
39
  .split(/\r?\n/)
35
40
  .map((part) => sanitizeLine(part))
@@ -37,7 +37,9 @@ export function registerRewind(pi: ExtensionAPI): void {
37
37
  });
38
38
 
39
39
  // A new prompt begins a task: the run resolves its snapshot decision up
40
- // front, so the `tool_call` guard can stay synchronous.
40
+ // front, so the `tool_call` guard can stay synchronous. A run started by a
41
+ // custom message (a wake) has no user prompt — `event.prompt` is undefined
42
+ // there despite its type — so `beginRun` accepts a missing prompt.
41
43
  pi.on("before_agent_start", async (event, ctx) => {
42
44
  await run.beginRun(event.prompt, ctx);
43
45
  });
@@ -54,7 +54,7 @@ export function createSnapshotRun(pi: ExtensionAPI, runtime: RewindRuntime): Sna
54
54
  runAutoSnapshots = false;
55
55
  };
56
56
 
57
- const beginRun = async (prompt: string, ctx: ExtensionContext): Promise<void> => {
57
+ const beginRun = async (prompt: string | undefined, ctx: ExtensionContext): Promise<void> => {
58
58
  reset();
59
59
  pendingPrompt = summarizePrompt(prompt);
60
60
  try {
@@ -10,6 +10,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent p
10
10
  pi install ./ # install the package
11
11
  ```
12
12
 
13
+ ## Tool rendering
14
+
15
+ How the `subagent` call and its result look in the transcript — the call line as a headline and the body indented under it:
16
+
17
+ <img src="../../assets/tools/subagent.png" alt="The subagent tool's call and result: the call line and an indented body with the two tasks, each agent's section rule, its summary, and the usage total" width="100%">
18
+
13
19
  ## What it does
14
20
 
15
21
  - **Isolated context.** Each subagent is a separate `pi` process with its own
@@ -168,7 +168,7 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
168
168
  },
169
169
 
170
170
  renderCall(args, theme, context) {
171
- return renderSubagentCall(args as SubagentArgs, theme, {
171
+ return renderSubagentCall(args, theme, {
172
172
  cwd: context.cwd,
173
173
  lastComponent: context.lastComponent,
174
174
  });