@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The review page's plan renderer: the DOM a frame draws, thrown away and rebuilt.
3
+ *
4
+ * The seventh capability out of the review client's template literal, and the one with the
5
+ * most to read: it builds every block, its comment rail, and the cards under it, then puts
6
+ * the reader back where they were. Reads arrive through getters and mutations leave through
7
+ * callbacks, so the client keeps every variable it owns — `activeIndex`, the quote a click
8
+ * is waiting for, the observer — and this function holds only the shape of one frame.
9
+ *
10
+ * Two rules it owns, both about a repaint not stealing the reader's place:
11
+ *
12
+ * - focus is restored only when it was inside the plan to begin with, so a frame the stream
13
+ * caused cannot pull the keyboard out of the page the reviewer is working in;
14
+ * - the active block's class is put back in place rather than by scrolling, so the arrow
15
+ * keys resume where they were instead of jumping to the top.
16
+ */
17
+
18
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
19
+ import type { ComposerDeps } from "./web-composer.ts";
20
+ import { blockElements, type IslandBlock, setActive } from "./web-blocks.ts";
21
+ import type { SectionWatcher } from "./web-outline.ts";
22
+
23
+ /** What `el` has to answer for a frame. Spliced beside it; declared for the checker. */
24
+ type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
25
+ declare const el: Build;
26
+
27
+ /** The quote a click on a block's control is waiting for: the reader's selection. */
28
+ export interface PendingQuote {
29
+ wrap: ClientNode;
30
+ quote: string;
31
+ }
32
+
33
+ /** What a repaint reads, and the callbacks it changes the client's state through. */
34
+ export interface PlanRenderDeps {
35
+ planEl: ClientNode | null;
36
+ outlineList: ClientNode | null;
37
+ /** The element that had focus before this frame, or null when nothing did. */
38
+ focused: ClientNode | null;
39
+ blocks(): readonly IslandBlock[];
40
+ annotations(): readonly { anchor: number }[];
41
+ activeIndex(): number;
42
+ setActiveIndex(index: number): void;
43
+ pendingQuote(): PendingQuote | null;
44
+ clearPendingQuote(): void;
45
+ /** The composer lifecycle, for the close a repaint begins with. */
46
+ composer(): ComposerDeps;
47
+ /** The block's content, from the Markdown half. */
48
+ renderBlock(wrap: ClientNode, block: IslandBlock): void;
49
+ /** The cards under one block, and the loose ones that lost their anchor. */
50
+ renderComments(scope: ClientNode, line: number): void;
51
+ renderCommentCards(scope: ClientNode, list: readonly unknown[]): void;
52
+ /** The text a reader selected inside a block, used as the next comment's quote. */
53
+ selectionIn(wrap: ClientNode): string;
54
+ open(wrap: ClientNode, block: IslandBlock, quote: string): void;
55
+ /** The facts and the header chip, repainted after the blocks. */
56
+ facts(): void;
57
+ updateState(): void;
58
+ }
59
+
60
+ /**
61
+ * Draw the plan, and put the reader back. Returns the section watcher it installed.
62
+ *
63
+ * The client's own zero-argument entry is `renderAll()`: it builds the frame's deps and
64
+ * hands them here, which is how the fragments keep calling a name that takes nothing.
65
+ */
66
+ export function renderPlanFrame(
67
+ deps: PlanRenderDeps,
68
+ closePlan: () => void,
69
+ observe: (
70
+ planEl: ClientNode | null,
71
+ list: ClientNode | null,
72
+ previous: SectionWatcher | null,
73
+ ) => SectionWatcher | null,
74
+ previousObserver: SectionWatcher | null,
75
+ ): SectionWatcher | null {
76
+ const { planEl } = deps;
77
+ if (!planEl) return previousObserver;
78
+ // Focus is only put back when it was inside this document to begin with.
79
+ const keepFocus = !!(deps.focused && deps.focused !== planEl && planEl.contains(deps.focused));
80
+ closePlan();
81
+ const scrollTop = planEl.scrollTop;
82
+ planEl.replaceChildren();
83
+ const anchored: Record<number, boolean> = {};
84
+ for (const block of deps.blocks()) {
85
+ if (block.kind === "blank") continue;
86
+ anchored[block.start] = true;
87
+ const wrap = el("div", "bf-block");
88
+ wrap.setAttribute("data-line", String(block.start));
89
+ wrap.setAttribute("data-kind", String(block.kind));
90
+ if (block.kind === "heading") wrap.id = "s-" + block.start;
91
+ deps.renderBlock(wrap, block);
92
+ const mine = deps.annotations().filter((annotation) => annotation.anchor === block.start);
93
+ if (mine.length > 0) wrap.classList.add("bf-block--commented");
94
+ const rail = el("div", "bf-block__rail");
95
+ const add = el("button", "bf-block__add");
96
+ add.type = "button";
97
+ add.textContent = "+";
98
+ add.setAttribute("aria-label", "Comment on this block");
99
+ add.addEventListener("click", function () {
100
+ // Read at click time, not at render time: a selection made after this frame is
101
+ // the quote the reader means.
102
+ const waiting = deps.pendingQuote();
103
+ const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
104
+ deps.clearPendingQuote();
105
+ deps.open(wrap, block, quoted);
106
+ });
107
+ rail.appendChild(add);
108
+ if (mine.length > 0) {
109
+ const badge = el("span", "bf-block__count");
110
+ badge.textContent = String(mine.length);
111
+ badge.title = mine.length === 1 ? "1 review comment" : mine.length + " review comments";
112
+ rail.appendChild(badge);
113
+ }
114
+ wrap.appendChild(rail);
115
+ wrap.addEventListener("focusin", function () {
116
+ const next = setActive(planEl, blockElements(planEl).indexOf(wrap));
117
+ if (next !== null) deps.setActiveIndex(next);
118
+ });
119
+ planEl.appendChild(wrap);
120
+ deps.renderComments(planEl, block.start);
121
+ }
122
+ const orphans = deps.annotations().filter((annotation) => !anchored[annotation.anchor]);
123
+ if (orphans.length > 0) {
124
+ const box = el("div", "bf-comments bf-comments--orphans");
125
+ const note = el("p");
126
+ note.textContent = "Comments not attached to a current block";
127
+ box.appendChild(note);
128
+ deps.renderCommentCards(box, orphans);
129
+ planEl.appendChild(box);
130
+ }
131
+ planEl.scrollTop = scrollTop;
132
+ // The DOM was rebuilt, so the active block's class is gone; restore it in place rather
133
+ // than scrolling, so the keyboard resumes where the reviewer was.
134
+ const activeIndex = deps.activeIndex();
135
+ const active = activeIndex >= 0 ? blockElements(planEl)[activeIndex] : null;
136
+ if (active) active.classList.add("bf-block--active");
137
+ // The focused element went with the old DOM, so put the tab stop back on the block's
138
+ // comment control: that is where adding, editing, or deleting left it.
139
+ if (keepFocus && active) {
140
+ const stop = active.querySelector(".bf-block__add");
141
+ if (stop) stop.focus();
142
+ }
143
+ deps.facts();
144
+ const observer = observe(planEl, deps.outlineList, previousObserver);
145
+ deps.updateState();
146
+ return observer;
147
+ }
148
+
149
+ /** The block renderer as source, for the client bundle. */
150
+ export const WEB_PLAN_JS = String(renderPlanFrame);
@@ -122,7 +122,6 @@ export async function handleReviewRequest(
122
122
  if (!parsed) return jsonError(400, "Malformed request URL.");
123
123
  const { pathname, search } = parsed;
124
124
 
125
- if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
126
125
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
127
126
  if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
128
127
 
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The review page's settlement: the four ways a review ends, and the one POST they share.
3
+ *
4
+ * The eighth capability out of the review client's template literal. Every write has the
5
+ * same shape — POST the annotations, read the answer, report it in the status line — and
6
+ * what differs is whether the review settles: the owner's save and approve finish it, a
7
+ * reviewer's post does not (the server keeps the review open so she can keep editing), and
8
+ * cancel is the owner's own decision and carries no body at all.
9
+ *
10
+ * The page owns its flags, its buttons, and its stream; this module owns the rules about
11
+ * them, so it reads through getters and reports changes through the callbacks it is given.
12
+ */
13
+
14
+ /** What a settlement reads of the page, and the changes it reports. */
15
+ export interface SettleDeps {
16
+ /** The path a write goes to, without its query string. */
17
+ token(): string;
18
+ role(): string;
19
+ /** The comments this writer may write: her own when she is a reviewer, all when owner. */
20
+ mine(): readonly unknown[];
21
+ annotations(): readonly unknown[];
22
+ /** Whether the review has already ended, by any of the three routes. */
23
+ settled(): boolean;
24
+ setStatus(text: string, kind?: string): void;
25
+ /** The three buttons a conflict disables, and the fourth a close does. */
26
+ buttons(): {
27
+ approve: { disabled: boolean } | null;
28
+ save: { disabled: boolean } | null;
29
+ post: { disabled: boolean } | null;
30
+ };
31
+ /** The draft this tab's work lives in: cleared once the server has it. */
32
+ clearDraft(): void;
33
+ /** The page is no longer dirty: the server has everything this tab wrote. */
34
+ markClean(): void;
35
+ /** The server bound this reviewer's name to her link: adopt it. */
36
+ adoptName(name: string): void;
37
+ /** Put a reload button beside the controls: the page cannot recover on its own. */
38
+ offerReload(): void;
39
+ /** Record how the review ended, stop following it, and stop offering to change it. */
40
+ markSettled(kind: "saved" | "approved" | "closed"): void;
41
+ settleStream(): void;
42
+ lockActions(): void;
43
+ /** Repaint the header chip and the facts. */
44
+ updateState(): void;
45
+ /** Refuse to leave a half-written comment, saying why. */
46
+ confirmNoDraft(): boolean;
47
+ post(url: string, body: unknown): Promise<{ ok: boolean; status: number; body: unknown }>;
48
+ /** A body-less POST, for the close that carries nothing. */
49
+ send(url: string): Promise<unknown>;
50
+ /** What a body-less conflict says, when the server gave no sentence of its own. */
51
+ planMoved: string;
52
+ }
53
+
54
+ /** What a settlement reports back: the flag it set, and whether the page is finished. */
55
+ export interface SettleResult {
56
+ outcome: "ok" | "conflict" | "failed";
57
+ reason?: string;
58
+ }
59
+
60
+ /**
61
+ * POST the annotations, and read the answer on both branches.
62
+ *
63
+ * A 409 disables the three write buttons and offers a reload, because the page's island is
64
+ * out of date and no retry from here can fix that; anything else surfaces the server's own
65
+ * sentence, which is why the body is parsed on both branches rather than only on success.
66
+ */
67
+ export function submitAnnotations(
68
+ deps: SettleDeps,
69
+ url: string,
70
+ okText: string,
71
+ failText: string,
72
+ onOk: (() => void) | null,
73
+ ): Promise<SettleResult> {
74
+ const payload = deps.role() === "reviewer" ? deps.mine() : deps.annotations();
75
+ return deps
76
+ .post(url + "?token=" + encodeURIComponent(deps.token()), { annotations: payload })
77
+ .then((result) => {
78
+ if (result.ok) {
79
+ deps.markClean();
80
+ deps.clearDraft();
81
+ deps.setStatus(okText, "saved");
82
+ if (onOk) onOk();
83
+ deps.updateState();
84
+ // The server binds a reviewer's name to her link on the first post, so a
85
+ // second tab of the same link posts under the same name.
86
+ const body = result.body as { author?: unknown } | null;
87
+ if (deps.role() === "reviewer" && body && typeof body.author === "string" && body.author) {
88
+ deps.adoptName(body.author);
89
+ }
90
+ return { outcome: "ok" as const };
91
+ }
92
+ if (result.status === 409) {
93
+ const buttons = deps.buttons();
94
+ if (buttons.approve) buttons.approve.disabled = true;
95
+ if (buttons.save) buttons.save.disabled = true;
96
+ if (buttons.post) buttons.post.disabled = true;
97
+ deps.offerReload();
98
+ const body = result.body as { error?: unknown } | null;
99
+ const reason = body && typeof body.error === "string" ? body.error : deps.planMoved;
100
+ deps.setStatus(reason, "error");
101
+ return { outcome: "conflict" as const, reason };
102
+ }
103
+ const body = result.body as { error?: unknown } | null;
104
+ throw new Error(body && typeof body.error === "string" ? body.error : "server returned " + result.status);
105
+ })
106
+ .catch((error: { message?: string }) => {
107
+ const reason = failText + (error && error.message ? error.message : "");
108
+ deps.setStatus(reason, "error");
109
+ return { outcome: "failed" as const, reason };
110
+ });
111
+ }
112
+
113
+ /** Save the annotations and finish the review: the owner's way to close an approved plan. */
114
+ export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
115
+ if (deps.settled()) return null;
116
+ if (deps.confirmNoDraft()) return null;
117
+ deps.setStatus("Saving\u2026");
118
+ return submitAnnotations(
119
+ deps,
120
+ "/api/feedback",
121
+ "Saved \u2014 return to Pi to continue.",
122
+ "Could not save: ",
123
+ function () {
124
+ deps.markSettled("saved");
125
+ deps.settleStream();
126
+ deps.lockActions();
127
+ },
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Post a reviewer's comments and keep the review open.
133
+ *
134
+ * A reviewer's write never settles the page: the server keeps the review open so she can
135
+ * keep editing her own comments, and closing the stream here would strand her.
136
+ */
137
+ export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | null {
138
+ if (deps.settled()) return null;
139
+ if (deps.confirmNoDraft()) return null;
140
+ deps.setStatus("Posting\u2026");
141
+ return submitAnnotations(
142
+ deps,
143
+ "/api/comment",
144
+ "Comments posted \u2014 the owner sees them now.",
145
+ "Could not post: ",
146
+ null,
147
+ );
148
+ }
149
+
150
+ /** Approve the plan, which finishes the review. */
151
+ export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
152
+ if (deps.settled()) return null;
153
+ deps.setStatus("Approving\u2026");
154
+ return submitAnnotations(
155
+ deps,
156
+ "/api/approve",
157
+ "Approved \u2014 return to Pi to continue.",
158
+ "Could not approve: ",
159
+ function () {
160
+ deps.markSettled("approved");
161
+ deps.settleStream();
162
+ deps.lockActions();
163
+ },
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Close the review without saving: only the owner may, and the page says so while it asks.
169
+ *
170
+ * The close carries no body, so it is the one write that does not go through
171
+ * `submitAnnotations`; its answer is a status line either way, because a tab that closed the
172
+ * review has nothing left to do with a failure.
173
+ */
174
+ export function cancelReview(deps: SettleDeps): Promise<void> | null {
175
+ if (deps.role() !== "owner" || deps.settled()) return null;
176
+ if (deps.confirmNoDraft()) return null;
177
+ deps.markSettled("closed");
178
+ deps.settleStream();
179
+ deps.lockActions();
180
+ deps.setStatus("Closing\u2026");
181
+ deps.updateState();
182
+ return deps
183
+ .send("/api/cancel?token=" + encodeURIComponent(deps.token()))
184
+ .catch(function () {})
185
+ .then(function () {
186
+ deps.setStatus("Closed \u2014 return to Pi.");
187
+ deps.updateState();
188
+ });
189
+ }
190
+
191
+ /** The settlement rules as source, for the client bundle. */
192
+ export const WEB_SETTLE_JS = [submitAnnotations, saveReview, postReviewComments, approveReview, cancelReview]
193
+ .map(String)
194
+ .join("\n\n");
@@ -12,6 +12,7 @@
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { messageOf } from "../../../lib/errors.ts";
14
14
  import { sanitizeLine } from "../../../lib/format.ts";
15
+ import { planModeRefusal } from "../../../lib/plan-entry.ts";
15
16
  import { isPlanModeEnabled } from "../../../lib/plan-state.ts";
16
17
  import { withRailsSuppressed } from "../../../lib/rails.ts";
17
18
  import {
@@ -75,7 +76,7 @@ async function loadReview(runtime: RewindRuntime, ctx: ExtensionCommandContext):
75
76
  return {
76
77
  ok: false,
77
78
  kind: "warning",
78
- message: "Plan mode is read-only. Approve the plan before reviewing and reverting its changes.",
79
+ message: planModeRefusal("reviewing and reverting its changes"),
79
80
  };
80
81
  }
81
82
 
@@ -15,6 +15,7 @@ import { listRow, statusRow } from "../../lib/tui/rows.ts";
15
15
  import type { TailRowLine } from "../../lib/tui/tool.ts";
16
16
  import { paint } from "../../lib/tui/tokens.ts";
17
17
  import { idSuffix } from "./format.ts";
18
+ import type { RosterState } from "../../lib/session-roster.ts";
18
19
  import type { BusMessage, ClaimRecord, SessionRecord, SessionStatus } from "./types.ts";
19
20
 
20
21
  /** Claim paths the focus block lists before it points at the pane. */
@@ -27,7 +28,7 @@ export function count(n: number, noun: string): string {
27
28
 
28
29
  /** One session on the board, with the claims it holds. */
29
30
  export interface SessionRow {
30
- session: SessionRecord & { unread: number };
31
+ session: SessionRecord & { unread: number; state: RosterState };
31
32
  /** Whether this row is the session reading the board. */
32
33
  self: boolean;
33
34
  claims: ClaimRecord[];
@@ -69,7 +70,7 @@ export function sessionRowLine(
69
70
  options: { selected?: boolean; marker?: boolean } = {},
70
71
  ): string {
71
72
  const label = `${sanitizeLine(row.session.label)}${row.self ? " · you" : ""}`;
72
- const counts = [`${row.session.unread} unread`];
73
+ const counts = [row.session.state === "gone" ? "gone" : `${row.session.unread} unread`];
73
74
  if (row.claims.length > 0) counts.push(count(row.claims.length, "claim"));
74
75
  return listRow(theme, width, {
75
76
  selected: options.selected === true,
@@ -147,7 +148,9 @@ export function summaryCells(
147
148
  ): string[] {
148
149
  const unread = rows.reduce((total, row) => total + row.session.unread, 0);
149
150
  const claims = rows.reduce((total, row) => total + row.claims.length, 0);
151
+ const gone = rows.filter((row) => row.session.state === "gone").length;
150
152
  const cells = [paint(theme, "meta", count(rows.length, "session"))];
153
+ if (gone > 0) cells.push(paint(theme, "bad", `${gone} gone`));
151
154
  if (unread > 0) cells.push(paint(theme, "label", `${unread} unread`));
152
155
  cells.push(paint(theme, "meta", count(claims, "claim")));
153
156
  if (watching) cells.push(paint(theme, "ok", watching.wake ? "watch wake" : "watch on"));
@@ -165,7 +168,8 @@ export function sessionDetailBlock(row: SessionRow, now: number): string[] {
165
168
  }
166
169
  lines.push(
167
170
  `mode: ${sanitizeLine(row.session.mode)}${SEPARATORS.item}id: ${idSuffix(row.session.id)}` +
168
- `${SEPARATORS.item}last seen: ${formatDuration(Math.max(0, now - row.session.lastSeen))} ago`,
171
+ `${SEPARATORS.item}last seen: ${formatDuration(Math.max(0, now - row.session.lastSeen))} ago` +
172
+ `${row.session.state === "gone" ? `${SEPARATORS.item}process gone` : ""}`,
169
173
  );
170
174
  return lines;
171
175
  }
@@ -40,7 +40,14 @@ import {
40
40
  import { createMailWatch, type WatchingState } from "./mail-watch.ts";
41
41
  import { createBusStore, releaseClaimed, type BusStore } from "./store.ts";
42
42
  import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
43
- import { BUS_DIR_NAME, SESSION_TTL_MS } from "../../lib/session-roster.ts";
43
+ import {
44
+ BUS_DIR_NAME,
45
+ type RosterSeams,
46
+ type RosterState,
47
+ rosterStateOf,
48
+ SESSION_TTL_MS,
49
+ } from "../../lib/session-roster.ts";
50
+ import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "../../lib/spawn.ts";
44
51
 
45
52
  export interface SessionBusOptions {
46
53
  now?: () => number;
@@ -48,6 +55,10 @@ export interface SessionBusOptions {
48
55
  dir?: string;
49
56
  /** Test seam: the process id this runtime registers as, so two can share one session id. */
50
57
  pid?: number;
58
+ /** Test seam: whether a pid is running, so a suite never depends on the host's pids. */
59
+ alive?: LivenessFn;
60
+ /** Test seam: the start-time seam the record is stamped with. */
61
+ startTokenFn?: StartTokenFn;
51
62
  /** Test seam: skip git and use this repository identity. */
52
63
  repo?: { root: string; key: string };
53
64
  /** Heartbeat interval; 0 disables it (tests). */
@@ -107,11 +118,6 @@ function messageId(now: number): string {
107
118
  return `${now}-${randomBytes(4).toString("hex")}`;
108
119
  }
109
120
 
110
- /** Whether a record's heartbeat is fresh: the rule the bus prunes and displays by. */
111
- function isLive(session: SessionRecord, at: number, ttlMs: number): boolean {
112
- return at - session.lastSeen <= ttlMs;
113
- }
114
-
115
121
  /** A label with this session's short id appended, inside the label budget. */
116
122
  function suffixLabel(label: string, id: string): string {
117
123
  const suffix = `-${shortId(id)}`;
@@ -124,6 +130,26 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
124
130
  const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
125
131
  const interval = options.interval ?? defaultInterval;
126
132
  const pid = options.pid ?? process.pid;
133
+ const seams: RosterSeams = { alive: options.alive ?? isPidAlive, token: options.startTokenFn ?? startTokenOf };
134
+ // Asked once, here: a reused pid is settled from the record afterwards, so no
135
+ // heartbeat tick spawns `ps`.
136
+ const processToken = (seams.token ?? startTokenOf)(pid);
137
+
138
+ /**
139
+ * What one record says about its session.
140
+ *
141
+ * The rule is the roster's, not a copy: the bus writes these records and the page
142
+ * reads them, and a second copy of the heartbeat rule is how a dead session keeps its
143
+ * label and its claims while the screen already says it is gone.
144
+ */
145
+ function stateOf(session: SessionRecord, at = now()): RosterState {
146
+ return rosterStateOf(session, at, sessionTtlMs, seams);
147
+ }
148
+
149
+ /** Whether a record is a session that can still answer. */
150
+ function isLive(session: SessionRecord, at = now()): boolean {
151
+ return stateOf(session, at) === "live";
152
+ }
127
153
  const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
128
154
  const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
129
155
 
@@ -161,6 +187,9 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
161
187
  mode,
162
188
  startedAt: now(),
163
189
  lastSeen: now(),
190
+ // Asked once, here: a reused pid is settled from the record, not by this session
191
+ // polling `ps` for its own heartbeat.
192
+ ...(processToken === undefined ? {} : { startToken: processToken }),
164
193
  };
165
194
  })();
166
195
  try {
@@ -202,14 +231,16 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
202
231
  }
203
232
 
204
233
  /** The labels other live sessions hold: a crashed session's label is not taken. */
205
- async function takenLabels(bus: BusStore, mine: string): Promise<Set<string>> {
234
+ async function takenLabels(bus: BusStore, mine: string): Promise<Map<string, SessionRecord>> {
206
235
  const at = now();
207
236
  const sessions = await bus.sessions();
208
- return new Set(
209
- sessions
210
- .filter((session) => session.id !== mine && isLive(session, at, sessionTtlMs))
211
- .map((session) => session.label.toLowerCase()),
212
- );
237
+ const taken = new Map<string, SessionRecord>();
238
+ // The record, not just the string: a refusal names the session that holds the label,
239
+ // which a set of lowercase labels could not do.
240
+ for (const session of sessions) {
241
+ if (session.id !== mine && isLive(session, at)) taken.set(session.label.toLowerCase(), session);
242
+ }
243
+ return taken;
213
244
  }
214
245
 
215
246
  /**
@@ -231,15 +262,25 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
231
262
  await store.writeSession(me);
232
263
  }
233
264
 
234
- /** The live session ids a prune must keep: this session is active by definition. */
235
- async function liveIds(): Promise<Set<string>> {
265
+ /**
266
+ * What a prune must keep, and what it must let go.
267
+ *
268
+ * `live` is who can still answer, and this session is active by definition. `gone` is
269
+ * who cannot: their rows survive on the heartbeat's clock, but the paths they held are
270
+ * nobody's, which is the half a lead waits for.
271
+ */
272
+ async function pruneSets(): Promise<{ live: Set<string>; gone: Set<string> }> {
236
273
  if (!store) throw new Error("session bus is not started.");
237
274
  const at = now();
238
- const active = new Set(
239
- (await store.sessions()).filter((session) => isLive(session, at, sessionTtlMs)).map((session) => session.id),
240
- );
241
- if (record) active.add(record.id);
242
- return active;
275
+ const live = new Set<string>();
276
+ const gone = new Set<string>();
277
+ for (const session of await store.sessions()) {
278
+ const state = stateOf(session, at);
279
+ if (state === "live") live.add(session.id);
280
+ else if (state === "gone") gone.add(session.id);
281
+ }
282
+ if (record) live.add(record.id);
283
+ return { live, gone };
243
284
  }
244
285
 
245
286
  async function prepare(): Promise<void> {
@@ -249,12 +290,14 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
249
290
  // session that finally calls a tool does not delete its own record.
250
291
  record.lastSeen = now();
251
292
  await store.writeSession(record);
252
- const sessions = await store.sessions();
253
- const active = new Set(
254
- sessions.filter((session) => isLive(session, now(), sessionTtlMs)).map((session) => session.id),
255
- );
256
- if (record) active.add(record.id);
257
- await store.prune({ now: now(), activeIds: active, sessionTtlMs, messageTtlMs });
293
+ const sets = await pruneSets();
294
+ await store.prune({
295
+ now: now(),
296
+ activeIds: sets.live,
297
+ goneIds: sets.gone,
298
+ sessionTtlMs,
299
+ messageTtlMs,
300
+ });
258
301
  }
259
302
 
260
303
  async function currentRecord(): Promise<SessionRecord> {
@@ -277,17 +320,28 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
277
320
  return store;
278
321
  }
279
322
 
280
- async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
281
- return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
323
+ async function withUnread(
324
+ rows: readonly SessionRecord[],
325
+ ): Promise<Array<SessionRecord & { unread: number; state: RosterState }>> {
326
+ const at = now();
327
+ return Promise.all(
328
+ rows.map(async (session) => ({
329
+ ...session,
330
+ unread: (await store?.unread(session.id)) ?? 0,
331
+ state: stateOf(session, at),
332
+ })),
333
+ );
282
334
  }
283
335
 
284
336
  /**
285
337
  * The bus as it stands, this session's own record included.
286
338
  *
287
- * `liveOnly` drops a session whose heartbeat went stale, and a claim held by
288
- * one, without writing or deleting anything, so a board can read it every
289
- * second. The prune itself stays with `prepare()` on the next tool or command
290
- * call, which is therefore what the screen's next action sees.
339
+ * `liveOnly` drops a session whose heartbeat went stale, and the claims of one whose
340
+ * process is gone, without writing or deleting anything, so a board can read it every
341
+ * second. A gone session stays listed — a lead needs a face for the paths it still
342
+ * holds — but its claims are nobody's, so they are not drawn. The prune itself stays
343
+ * with `prepare()` on the next tool or command call, which is therefore what the
344
+ * screen's next action sees.
291
345
  */
292
346
  async function readStatus(
293
347
  me: { id: string; label: string; onBus: boolean },
@@ -296,10 +350,10 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
296
350
  if (!store) throw new Error("session bus is not started.");
297
351
  const records = await store.sessions();
298
352
  const sessions = liveOnly
299
- ? records.filter((session) => session.id === record?.id || isLive(session, now(), sessionTtlMs))
353
+ ? records.filter((session) => session.id === record?.id || stateOf(session) !== "quiet")
300
354
  : records;
301
- const live = new Set(sessions.map((session) => session.id));
302
- const claims = (await store.claims()).filter((claim) => !liveOnly || live.has(claim.owner));
355
+ const ours = new Set(sessions.filter((session) => stateOf(session) !== "gone").map((session) => session.id));
356
+ const claims = (await store.claims()).filter((claim) => !liveOnly || ours.has(claim.owner));
303
357
  return {
304
358
  me: { id: me.id, label: me.label },
305
359
  onBus: me.onBus,
@@ -312,9 +366,10 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
312
366
  async function rename(label: string): Promise<string> {
313
367
  if (!store || !record) throw new Error("session bus is not started.");
314
368
  const candidate = normalizeLabel(label, record.label);
315
- if ((await takenLabels(store, record.id)).has(candidate.toLowerCase())) {
316
- throw new Error(`the label "${candidate}" is already in use.`);
317
- }
369
+ // The bus's own answer to "who has it": the session record, named by its id, because
370
+ // the label it holds is the one the caller just asked for.
371
+ const holder = (await takenLabels(store, record.id)).get(candidate.toLowerCase());
372
+ if (holder) throw new Error(`the label "${candidate}" is already in use by session ${holder.id}.`);
318
373
  record.label = candidate;
319
374
  record.lastSeen = now();
320
375
  await store.writeSession(record);
@@ -382,7 +437,14 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
382
437
  // A reader does not join: an unjoined session prunes and reads, and is not
383
438
  // listed by the act of looking. Only an addressable action joins.
384
439
  if (!joined) {
385
- await store.prune({ now: now(), activeIds: await liveIds(), sessionTtlMs, messageTtlMs });
440
+ const sets = await pruneSets();
441
+ await store.prune({
442
+ now: now(),
443
+ activeIds: sets.live,
444
+ goneIds: sets.gone,
445
+ sessionTtlMs,
446
+ messageTtlMs,
447
+ });
386
448
  return readStatus({ id: record.id, label: record.label, onBus: false }, false);
387
449
  }
388
450
  const me = await currentRecord();