@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -13,11 +13,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Type, type Static } from "typebox";
14
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
15
  import { errorResult, textResult } from "../../lib/tool-result.ts";
16
- import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
16
+ import { callLine, detailsOrLine, isTailResult, TailResult } from "../../lib/tui/tool.ts";
17
17
  import { formatClaim, formatInbox, formatJoin, formatRelease, formatSend, formatStatus } from "./format.ts";
18
18
  import { normalizeAction, TOOL_NAME } from "./protocol.ts";
19
19
  import type { SessionBus } from "./runtime.ts";
20
- import { collapsedLine, resultSections, SessionResult } from "./result.ts";
20
+ import { collapsedLine, resultSections } from "./result.ts";
21
+ import type { BusRow } from "./rows.ts";
21
22
  import type { SessionDetails } from "./types.ts";
22
23
 
23
24
  export { TOOL_NAME };
@@ -183,8 +184,8 @@ export function registerTools(pi: ExtensionAPI, bus: SessionBus): void {
183
184
  text.setText(collapsedLine(theme, value));
184
185
  return text;
185
186
  }
186
- const view =
187
- context?.lastComponent instanceof SessionResult ? context.lastComponent : new SessionResult(sections, theme);
187
+ const previous = context?.lastComponent;
188
+ const view = isTailResult<BusRow>(previous) ? previous : new TailResult<BusRow>(sections, theme);
188
189
  view.setInput(sections, theme);
189
190
  return view;
190
191
  },
@@ -96,7 +96,7 @@ function boardKey(board: BoardSnapshot | undefined): string {
96
96
  return [
97
97
  ...board.status.sessions.map(
98
98
  (session) =>
99
- `${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}`,
99
+ `${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}:${session.state}`,
100
100
  ),
101
101
  ...board.status.claims.map((claim) => `${claim.path}:${claim.owner}:${claim.ownerLabel}:${claim.at}`),
102
102
  board.status.watching ? `${board.status.watching.armed}:${board.status.watching.wake}` : "",
@@ -5,6 +5,7 @@
5
5
  * `store.ts` and the lifecycle in `runtime.ts`.
6
6
  */
7
7
 
8
+ import type { RosterState } from "../../lib/session-roster.ts";
8
9
  import type { MessageKind, SessionMode } from "./protocol.ts";
9
10
 
10
11
  /** One registered Pi session. */
@@ -12,6 +13,14 @@ export interface SessionRecord {
12
13
  id: string;
13
14
  label: string;
14
15
  pid: number;
16
+ /**
17
+ * That process's start time, when the host can name one.
18
+ *
19
+ * It is what tells the record's process apart from an unrelated one that has since
20
+ * been given the same pid, so `gone` does not have to wait for the heartbeat to age.
21
+ * Absent on a platform without `ps`, and on a record written before this existed.
22
+ */
23
+ startToken?: string;
15
24
  /** Working directory of the session; the worktree root when there is one. */
16
25
  cwd: string;
17
26
  /** Repository root the session belongs to, for relative claims. */
@@ -57,7 +66,7 @@ export interface SessionStatus {
57
66
  me: { id: string; label: string };
58
67
  /** False while this session has only read the bus: it joins by naming itself. */
59
68
  onBus: boolean;
60
- sessions: Array<SessionRecord & { unread: number }>;
69
+ sessions: Array<SessionRecord & { unread: number; state: RosterState }>;
61
70
  claims: ClaimRecord[];
62
71
  /** Present only when this session armed its inbox watch. */
63
72
  watching?: { armed: boolean; wake: boolean };
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
+ import { planModeRefusal } from "../../lib/plan-entry.ts";
10
11
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
11
12
  import { withRailsSuppressed } from "../../lib/rails.ts";
12
13
  import type { BackgroundRuntime } from "./background.ts";
@@ -20,7 +21,7 @@ async function openAgents(
20
21
  ctx: ExtensionCommandContext,
21
22
  ): Promise<void> {
22
23
  if (isPlanModeEnabled(ctx.sessionManager.getBranch())) {
23
- ctx.ui.notify("Plan mode is read-only. Approve the plan before managing background runs.", "warning");
24
+ ctx.ui.notify(planModeRefusal("managing background runs"), "warning");
24
25
  return;
25
26
  }
26
27
 
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * The Team tab is the same round with a mouse; a lead reading a TUI should not have to
5
5
  * open a browser to see what needs a decision, so the command prints the rollup the
6
- * tool returns and one line saying who is live. It reads the cards and the bus's records
7
- * and writes nothing: acting on the round is `team`'s tool (`assign`, `reclaim`) or
8
- * `board`'s.
6
+ * tool returns, the work a dead owner left behind, and one line saying who is live. It
7
+ * reads the cards and the bus's records and writes nothing: acting on the round is
8
+ * `team`'s tool (`assign`, `reclaim`) or `board`'s.
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
- import type { Card } from "../../lib/board/card.ts";
12
+ import { type Card, cardRef } from "../../lib/board/card.ts";
13
13
  import { buildRound } from "../../lib/board/round.ts";
14
14
  import { formatRound, plural } from "../../lib/board/round-text.ts";
15
15
  import type { BoardSession } from "../../lib/board/session.ts";
@@ -40,7 +40,9 @@ export function registerCommands(pi: ExtensionAPI, session: BoardSession, deps:
40
40
  const [word = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
41
41
  if (word === "join") {
42
42
  const label = rest.join(" ").trim();
43
- const joined = await team.join(ctx, label === "" ? undefined : label);
43
+ // The one naming action: a typed label is also Pi's session name, which is what
44
+ // `/team join` is for. Joining without one leaves both to the bus.
45
+ const joined = await team.join(ctx, label === "" ? {} : { label, name: true });
44
46
  ctx.ui.notify(joinedLine(joined), joined.ok ? "info" : "warning");
45
47
  return;
46
48
  }
@@ -77,26 +79,58 @@ export function roundText(cards: readonly Card[], now: number, roster?: readonly
77
79
  const round = formatRound(cards, buildRound(cards, now)).split("\n");
78
80
  const shown = round.slice(0, MAX_ROUND_LINES);
79
81
  if (round.length > MAX_ROUND_LINES) shown.push(`… ${plural(round.length - MAX_ROUND_LINES, "line")} more`);
82
+ for (const line of goneLines(cards, roster, now)) shown.push(line);
80
83
  const who = presenceLine(roster, now);
81
84
  if (who !== undefined) shown.push(who);
82
85
  return shown.join("\n");
83
86
  }
84
87
 
85
88
  /**
86
- * `live: a, b · not listening: b · quiet: c`, or nothing when nobody is here.
89
+ * The work a dead owner left behind, as the line that says who takes it.
87
90
  *
88
- * Three facts, and the middle one is the one a lead can act on: a live session that is
91
+ * One line per gone owner that still holds cards, before the presence line: `gone: b` at
92
+ * the end of that line says who died, and this one says what to do about it. A card that
93
+ * waits on another is left to the board, whose rules would refuse the claim anyway.
94
+ */
95
+ function goneLines(cards: readonly Card[], roster: readonly RosterSession[] | undefined, now: number): string[] {
96
+ if (roster === undefined) return [];
97
+ const gone = new Map<string, string>();
98
+ for (const session of roster) {
99
+ if (rosterStateOf(session, now) === "gone") gone.set(session.label.toLowerCase(), session.label);
100
+ }
101
+ if (gone.size === 0) return [];
102
+ const byOwner = new Map<string, string[]>();
103
+ for (const card of cards) {
104
+ if (card.owner === undefined || card.status === "done") continue;
105
+ const label = gone.get(card.owner.label.toLowerCase());
106
+ if (label === undefined) continue;
107
+ byOwner.set(label, [...(byOwner.get(label) ?? []), cardRef(card.id)]);
108
+ }
109
+ return [...byOwner.entries()].map(([label, refs]) => `reclaim ${refs.join(", ")} — ${label} is not running`);
110
+ }
111
+
112
+ /**
113
+ * `live: a, b · not listening: b · quiet: c · gone: d`, or nothing when nobody is here.
114
+ *
115
+ * Four facts, and the middle one is the one a lead can act on: a live session that is
89
116
  * not listening reads mail at its next turn rather than being woken for it. It is only
90
117
  * named when its record says so — a session that never wrote the field is not accused of
91
- * ignoring anything.
118
+ * ignoring anything. `gone` is the loudest and comes last, because it is the one a lead
119
+ * answers by reclaiming rather than by waiting.
92
120
  */
93
121
  function presenceLine(roster: readonly RosterSession[] | undefined, now: number): string | undefined {
94
122
  if (roster === undefined || roster.length === 0) return undefined;
95
123
  const live: string[] = [];
96
124
  const quiet: string[] = [];
125
+ const dead: string[] = [];
97
126
  const silent: string[] = [];
98
127
  for (const session of roster) {
99
- if (rosterStateOf(session, now) !== "live") {
128
+ const state = rosterStateOf(session, now);
129
+ if (state === "gone") {
130
+ dead.push(session.label);
131
+ continue;
132
+ }
133
+ if (state !== "live") {
100
134
  quiet.push(session.label);
101
135
  continue;
102
136
  }
@@ -107,6 +141,7 @@ function presenceLine(roster: readonly RosterSession[] | undefined, now: number)
107
141
  live.length > 0 ? `live: ${live.join(", ")}` : "",
108
142
  silent.length > 0 ? `not listening: ${silent.join(", ")}` : "",
109
143
  quiet.length > 0 ? `quiet: ${quiet.join(", ")}` : "",
144
+ dead.length > 0 ? `gone: ${dead.join(", ")}` : "",
110
145
  ];
111
146
  return parts.filter((part) => part !== "").join(" · ");
112
147
  }
@@ -86,6 +86,20 @@ export function formatOfferFailure(card: Card, reason: string): string {
86
86
  return `${cardRef(card.id)} was not offered: ${reason}\n The card is unchanged.`;
87
87
  }
88
88
 
89
+ /**
90
+ * The broadcast landed and recording the offer then lost a race.
91
+ *
92
+ * The peers have an event for a card somebody else has since claimed, moved, or taken
93
+ * over, so the correction says the offer stands as a message and the card is what the
94
+ * board now says — not that nothing happened.
95
+ */
96
+ export function formatRacedOffer(card: Card, reason: string): string {
97
+ return (
98
+ `${cardRef(card.id)} was offered, but the card changed before the offer could be recorded: ${reason}\n` +
99
+ " The peers have the message; the card is as the board shows it now."
100
+ );
101
+ }
102
+
89
103
  /**
90
104
  * The send landed and the assignment then lost a race.
91
105
  *
@@ -4,7 +4,8 @@
4
4
  * `board` owns the cards and never touches the session bus; `team` owns the two
5
5
  * actions that need it. `assign` hands a card to a session that is already running
6
6
  * and sends it a handoff — dispatch cannot start a peer's turn, and the page and the
7
- * docs say so — and `reclaim` takes a card back when its owner has gone quiet. Its one
7
+ * docs say so — and `reclaim` takes a card back when its owner is gone or has gone quiet.
8
+ * Its one
8
9
  * command, `/team`, prints the round the board's Team tab draws, without a browser.
9
10
  *
10
11
  * Either extension works without the other: `board` is the work list any session
@@ -24,17 +24,26 @@ import { failedResult, type ToolFailure, textResult } from "../../lib/tool-resul
24
24
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
25
25
  import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
26
26
  import { plural } from "../../lib/board/round-text.ts";
27
+ import { strayField } from "../../lib/stray-fields.ts";
27
28
  import { type Dispatch, handoffText, notListening, offerText, ownerFor, peerFor, sessionDispatch } from "./dispatch.ts";
28
29
  import {
29
30
  formatHandoff,
30
31
  formatHandoffFailure,
31
32
  formatOffer,
32
33
  formatOfferFailure,
34
+ formatRacedOffer,
33
35
  formatRacedAssignment,
34
36
  formatReclaimed,
35
37
  } from "./format.ts";
36
38
  import { TeamParams } from "./schema.ts";
37
- import { normalizeTeamAction, TEAM_ACTIONS, TEAM_TOOL, type TeamAction, type TeamDetails } from "./types.ts";
39
+ import {
40
+ ACTION_FIELDS,
41
+ normalizeTeamAction,
42
+ TEAM_ACTIONS,
43
+ TEAM_TOOL,
44
+ type TeamAction,
45
+ type TeamDetails,
46
+ } from "./types.ts";
38
47
 
39
48
  /** The parameters as the schema validates them. */
40
49
  type TeamArgs = Static<typeof TeamParams>;
@@ -74,7 +83,8 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
74
83
  "Open the board with `/board` or work a card first, so this session and its peers are on the team.",
75
84
  "Offer a card nobody has taken; assign one you mean a particular peer to hold.",
76
85
  "When a result says `Not listening`, say so: the message waits for that peer's next turn.",
77
- "Reclaim a card whose owner has gone quiet rather than editing someone else's work.",
86
+ "Reclaim a card whose owner is gone or has gone quiet rather than editing someone else's work. A " +
87
+ "`gone:` owner's process is not running, so what it held is anyone's to reclaim.",
78
88
  ],
79
89
  parameters: TeamParams,
80
90
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
@@ -86,6 +96,10 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
86
96
  if (!action) {
87
97
  return refusal("assign", `unknown action "${String(args.action)}"; use ${TEAM_ACTIONS.join(" or ")}.`);
88
98
  }
99
+ // Before the store and before the card: a refused call has done nothing, so it
100
+ // cannot have read a card or sent a message on its way to the refusal.
101
+ const stray = strayField(action, args, ACTION_FIELDS);
102
+ if (stray !== undefined) return refusal(action, stray);
89
103
  const id = normalizeCardId(args.card);
90
104
  if (id === undefined) return refusal(action, `"${String(args.card)}" is not a card id.`);
91
105
  const me = session.who(ctx, ctx.cwd);
@@ -93,7 +107,7 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
93
107
  const store = await session.store(ctx.cwd);
94
108
  const now = session.now();
95
109
  if (action === "reclaim") return await reclaimCard(store, me, id, now);
96
- if (action === "offer") return await offerCard(store, id, args.note, now, dispatchFor(ctx));
110
+ if (action === "offer") return await offerCard(store, me, id, args.note, now, dispatchFor(ctx));
97
111
  const to = typeof args.to === "string" ? args.to.trim() : "";
98
112
  if (to === "") return refusal("assign", 'assign needs a peer: pass `to`, e.g. "billing-core".');
99
113
  return await assignCard(store, me, id, to, args.note, now, dispatchFor(ctx));
@@ -180,14 +194,17 @@ async function assignCard(
180
194
  const OFFER_CLAIMANT: Actor = { label: "*" };
181
195
 
182
196
  /**
183
- * `offer`: check the claim's preconditions, broadcast the card, and write nothing.
197
+ * `offer`: check the claim's preconditions, broadcast the card, then record the offer.
184
198
  *
185
- * The order matters less here than in `assign`, because nothing is written: a failed
186
- * broadcast leaves the card exactly as it was, and the first peer to claim is what makes
187
- * an offer into work. Two peers racing are settled by the store's compare-and-set.
199
+ * The broadcast comes first and the record second, because a record of an offer nobody
200
+ * received would be worse than an offer with no record: the card is unchanged by a failed
201
+ * send, and the first peer to claim is what makes an offer into work. What the record
202
+ * adds is the one thing an event cannot carry — when it was made — so a lead can tell an
203
+ * offer nobody took from a card nobody has looked at.
188
204
  */
189
205
  async function offerCard(
190
206
  store: BoardStore,
207
+ me: Actor,
191
208
  id: string,
192
209
  note: string | undefined,
193
210
  now: number,
@@ -206,11 +223,15 @@ async function offerCard(
206
223
  } catch (error) {
207
224
  return refusal("offer", formatOfferFailure(card, messageOf(error)), card);
208
225
  }
226
+ // The peers have the event, so a card that changed underneath is reported as raced
227
+ // rather than refused: the message is already in their inboxes.
228
+ const recorded = await store.mutate(id, { kind: "offer" }, me, now);
229
+ if (!recorded.ok) return refusal("offer", formatRacedOffer(recorded.card ?? card, recorded.error), recorded.card);
209
230
  const peers = await dispatch.peers().catch(() => undefined);
210
231
  const silent = notListening(peers, delivered);
211
- return textResult(formatOffer(card, delivered, silent), {
232
+ return textResult(formatOffer(recorded.card, delivered, silent), {
212
233
  action: "offer",
213
- card,
234
+ card: recorded.card,
214
235
  delivered,
215
236
  ...(silent.length === 0 ? {} : { notListening: silent }),
216
237
  });
@@ -42,6 +42,23 @@ export interface TeamDetails {
42
42
  error?: string;
43
43
  }
44
44
 
45
+ /**
46
+ * The fields each action acts on: what a call may carry, per action.
47
+ *
48
+ * The parameters are one flat object, so a field is published to every action at once,
49
+ * and only this table stops one reaching an action that would drop it. `to` is `assign`'s
50
+ * alone: an `offer` that named a peer was broadcast to every live session instead, and a
51
+ * `reclaim` that named one took the card back from whoever held it.
52
+ *
53
+ * A test holds the other direction — every published parameter but `action` is claimed
54
+ * here — so a field cannot be published without an action that acts on it.
55
+ */
56
+ export const ACTION_FIELDS: Record<TeamAction, readonly string[]> = {
57
+ assign: ["card", "to", "note"],
58
+ offer: ["card", "note"],
59
+ reclaim: ["card"],
60
+ };
61
+
45
62
  /** Whether a value is one of the actions, so an unknown one is a refusal and not a crash. */
46
63
  export function normalizeTeamAction(raw: unknown): TeamAction | undefined {
47
64
  return typeof raw === "string" && TEAM_ACTIONS.includes(raw as TeamAction) ? (raw as TeamAction) : undefined;
@@ -60,7 +60,10 @@ export default function todo(pi: ExtensionAPI) {
60
60
  pi.on("tool_execution_end", (event) => {
61
61
  if (event.isError) return;
62
62
  const annotations = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
63
- if (!isMutatingTool(event.toolName, annotations)) return;
63
+ // The result is the only place a call's own record of what it did survives to this
64
+ // event, which is why the board's read action is read from here.
65
+ const details = (event.result as { details?: unknown } | undefined)?.details;
66
+ if (!isMutatingTool(event.toolName, annotations, details)) return;
64
67
  runtime.noteWork(isFileWrite(event.toolName, annotations));
65
68
  });
66
69
 
@@ -14,7 +14,8 @@
14
14
  */
15
15
 
16
16
  import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
17
- import { TODO_TOOL } from "../../lib/tool-names.ts";
17
+ import { isBoardRead } from "../../lib/policy.ts";
18
+ import { BOARD_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
18
19
  import { currentTodo, hasOpenTodos } from "./state.ts";
19
20
  import type { Todo } from "./types.ts";
20
21
 
@@ -32,9 +33,14 @@ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit"
32
33
  * Whether a finished tool call counts as work for lag detection. The `todo`
33
34
  * tool never does (it is the update itself), a known mutator always does, and
34
35
  * any other tool counts only when it explicitly declares `readOnlyHint: false`.
36
+ *
37
+ * `board` is the one mutator whose read face is provable from what the call
38
+ * returned: a result whose `details` say `action: "list"` read the cards and changed
39
+ * nothing, so it counts like every other reader.
35
40
  */
36
- export function isMutatingTool(toolName: string, annotations?: ToolAnnotations): boolean {
41
+ export function isMutatingTool(toolName: string, annotations?: ToolAnnotations, details?: unknown): boolean {
37
42
  if (toolName === TODO_TOOL) return false;
43
+ if (toolName === BOARD_TOOL && isBoardRead(details)) return false;
38
44
  if (MUTATING_TOOL_NAMES.has(toolName)) return true;
39
45
  return annotations?.readOnlyHint === false;
40
46
  }
@@ -13,6 +13,7 @@ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tu
13
13
  import { clip } from "../../lib/tui/columns.ts";
14
14
  import { emptyState, listRow } from "../../lib/tui/rows.ts";
15
15
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
+ import { RenderCache } from "../../lib/tui/render-cache.ts";
16
17
  import { titleRule } from "../../lib/tui/rules.ts";
17
18
  import { paint } from "../../lib/tui/tokens.ts";
18
19
  import {
@@ -57,10 +58,19 @@ function todoLine(todos: Todo[], theme: Theme): string {
57
58
  return `${head}${paint(theme, "faint", SEPARATORS.item)}${todoLabel(current, theme)}`;
58
59
  }
59
60
 
60
- /** Transcript result block: the indented glyph rail, wrapping at the render width. */
61
+ /**
62
+ * Transcript result block: the indented glyph rail, wrapping at the render width.
63
+ *
64
+ * `render` runs on every frame Pi draws, for every block in the transcript, so the
65
+ * rail is memoized on the width (`RenderCache`) and rebuilt only when
66
+ * `setInput` hands over a new list or `invalidate` says the theme moved. The
67
+ * caller must go through `setInput`: mutating a list in place would leave the
68
+ * block showing what it drew last.
69
+ */
61
70
  export class TodoResult implements Component {
62
71
  private input: TodoRailInput;
63
72
  private theme: Theme;
73
+ private readonly cache = new RenderCache();
64
74
 
65
75
  constructor(input: TodoRailInput, theme: Theme) {
66
76
  this.input = input;
@@ -71,14 +81,22 @@ export class TodoResult implements Component {
71
81
  setInput(input: TodoRailInput, theme: Theme): void {
72
82
  this.input = input;
73
83
  this.theme = theme;
84
+ this.cache.invalidate();
74
85
  }
75
86
 
76
- invalidate(): void {}
87
+ invalidate(): void {
88
+ this.cache.invalidate();
89
+ }
77
90
 
78
91
  render(width: number): string[] {
92
+ const w = Math.max(1, width);
93
+ return this.cache.lines(w, undefined, () => this.build(w));
94
+ }
95
+
96
+ private build(w: number): string[] {
79
97
  // The leading blank line separates the rail from the call header, matching
80
98
  // the built-ins' header/output gap.
81
- return ["", ...todoRailLines(this.input, this.theme, Math.max(1, width))];
99
+ return ["", ...todoRailLines(this.input, this.theme, w)];
82
100
  }
83
101
  }
84
102