@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -14,15 +14,15 @@
14
14
  */
15
15
 
16
16
  import type { Theme } from "@earendil-works/pi-coding-agent";
17
- import { sanitizeLine, stripControlChars } from "../../lib/format.ts";
18
- import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
19
- import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
20
- import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
21
- import { keyValueRow, statusRow } from "../../lib/tui/rows.ts";
22
- import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
23
- import type { BackgroundRun, BackgroundStatus } from "./background.ts";
24
- import { canCollect } from "./background.ts";
25
- import { formatBackgroundDuration, formatUsageStats } from "./format.ts";
17
+ import { sanitizeLine, stripControlChars } from "../../../lib/format.ts";
18
+ import type { ScreenHint, ViewportRowsSource } from "../../../lib/tui/chrome.ts";
19
+ import { DockListScreen, type DetailContents } from "../../../lib/tui/dock-screen.ts";
20
+ import { GLYPHS, SEPARATORS } from "../../../lib/tui/glyphs.ts";
21
+ import { keyValueRow, statusRow } from "../../../lib/tui/rows.ts";
22
+ import { paint, type TuiRole } from "../../../lib/tui/tokens.ts";
23
+ import type { BackgroundRun, BackgroundStatus } from "../background.ts";
24
+ import { canCollect } from "../background.ts";
25
+ import { formatBackgroundDuration, formatUsageStats } from "../format.ts";
26
26
 
27
27
  /** One status's glyph, role, and readable label. */
28
28
  function statusView(status: BackgroundStatus): { glyph: string; role: TuiRole; label: string } {
@@ -164,7 +164,7 @@ export class AgentsBoardComponent extends DockListScreen<BackgroundRun> {
164
164
  const focused = this.focusedRow;
165
165
  const hints: ScreenHint[] = ["Esc close", { text: "Enter detail", color: "accent" }];
166
166
  if (focused && canCollect(focused)) hints.push({ text: "c collect", color: "accent" });
167
- if (focused?.status === "running") hints.push({ text: "x cancel", color: "warning" });
167
+ if (focused?.status === "running") hints.push({ text: "x cancel", color: "warn" });
168
168
  hints.push("↑/↓ select");
169
169
  return hints;
170
170
  }
@@ -172,7 +172,7 @@ export class AgentsBoardComponent extends DockListScreen<BackgroundRun> {
172
172
  protected detailHints(run: BackgroundRun | undefined): ScreenHint[] {
173
173
  const hints: ScreenHint[] = ["Esc back"];
174
174
  if (run && canCollect(run)) hints.push({ text: "c collect", color: "accent" });
175
- if (run?.status === "running") hints.push({ text: "x cancel", color: "warning" });
175
+ if (run?.status === "running") hints.push({ text: "x cancel", color: "warn" });
176
176
  hints.push("j/k scroll");
177
177
  return hints;
178
178
  }
@@ -5,7 +5,7 @@
5
5
  * open a browser to see what needs a decision, so the command prints the rollup the
6
6
  * tool returns, the work a dead owner left behind, and one line saying who is live. It
7
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.
8
+ * `team`'s tool (`assign`, `offer`, `reclaim`, `claim`) or `board`'s.
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
@@ -13,8 +13,9 @@ 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";
16
- import { createTeamJoin, type JoinResult, type TeamJoin } from "../../lib/session-tool.ts";
16
+ import { createTeamJoin, type JoinResult, type TeamJoin, type TeamJoinInput } from "../../lib/session-tool.ts";
17
17
  import { readRoster, type RosterSession, rosterStateOf } from "../../lib/session-roster.ts";
18
+ import { sessionNameOf } from "../../lib/session-label.ts";
18
19
  import { formatJoined, formatNotJoined } from "./format.ts";
19
20
 
20
21
  /** The command's name, which is also the word a user types. */
@@ -40,7 +41,13 @@ export function registerCommands(pi: ExtensionAPI, session: BoardSession, deps:
40
41
  const [word = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
41
42
  if (word === "join") {
42
43
  const label = rest.join(" ").trim();
43
- const joined = await team.join(ctx, label === "" ? undefined : label);
44
+ // A typed label is the one naming action: it is also Pi's session name, which is what
45
+ // `/team join` is for. With no label, a session Pi already names keeps that name and
46
+ // the bus derives it; one with no name of its own draws a preset teammate name
47
+ // instead of landing on the repository's basename, and the drawn name renames nothing
48
+ // because it is the team's, not the user's.
49
+ const input: TeamJoinInput = label === "" ? await unnamedJoin(session, ctx) : { label, name: true };
50
+ const joined = await team.join(ctx, input);
44
51
  ctx.ui.notify(joinedLine(joined), joined.ok ? "info" : "warning");
45
52
  return;
46
53
  }
@@ -61,6 +68,18 @@ export function registerCommands(pi: ExtensionAPI, session: BoardSession, deps:
61
68
  });
62
69
  }
63
70
 
71
+ /**
72
+ * What a bare `/team join` asks for: nothing, when Pi already names the session and the
73
+ * bus can derive that, and otherwise the name the board draws for it.
74
+ *
75
+ * A drawn name is the team's, not the user's, so `name` is never set here: only a typed
76
+ * label is meant to rename the footer.
77
+ */
78
+ async function unnamedJoin(session: BoardSession, ctx: ExtensionCommandContext): Promise<TeamJoinInput> {
79
+ if ((sessionNameOf(ctx) ?? "").trim() !== "") return {};
80
+ return { label: (await session.who(ctx, ctx.cwd)).label };
81
+ }
82
+
64
83
  /** `/team join`'s line: who this session is on the team, or why it is on none. */
65
84
  export function joinedLine(result: JoinResult): string {
66
85
  return result.ok ? formatJoined(result.label, result.watching) : formatNotJoined(result.error);
@@ -74,7 +93,7 @@ export function joinedLine(result: JoinResult): string {
74
93
  */
75
94
  export function roundText(cards: readonly Card[], now: number, roster?: readonly RosterSession[]): string {
76
95
  if (cards.length === 0) return "team: nothing to report — the board is empty";
77
- const round = formatRound(cards, buildRound(cards, now)).split("\n");
96
+ const round = formatRound(cards, buildRound(cards, now), roster).split("\n");
78
97
  const shown = round.slice(0, MAX_ROUND_LINES);
79
98
  if (round.length > MAX_ROUND_LINES) shown.push(`… ${plural(round.length - MAX_ROUND_LINES, "line")} more`);
80
99
  for (const line of goneLines(cards, roster, now)) shown.push(line);
@@ -99,7 +118,7 @@ function goneLines(cards: readonly Card[], roster: readonly RosterSession[] | un
99
118
  if (gone.size === 0) return [];
100
119
  const byOwner = new Map<string, string[]>();
101
120
  for (const card of cards) {
102
- if (card.owner === undefined || card.status === "done") continue;
121
+ if (card.owner === undefined || card.lane === "done") continue;
103
122
  const label = gone.get(card.owner.label.toLowerCase());
104
123
  if (label === undefined) continue;
105
124
  byOwner.set(label, [...(byOwner.get(label) ?? []), cardRef(card.id)]);
@@ -14,6 +14,7 @@
14
14
  import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
15
15
  import type { Card } from "../../lib/board/card.ts";
16
16
  import { cardRef, type CardOwner } from "../../lib/board/card.ts";
17
+ import type { Actor } from "../../lib/board/rules.ts";
17
18
  import { listPeers, MAX_MESSAGE_TEXT, type Peer, send as sendTo } from "../../lib/session-tool.ts";
18
19
 
19
20
  /** How a handoff reaches a peer, and what the bus can say about the peers there are. */
@@ -42,9 +43,9 @@ export function handoffText(card: Card, to: string, note?: string): string {
42
43
  const lines = [
43
44
  `Handoff: ${cardRef(card.id)} "${card.title}" is yours, ${to}.`,
44
45
  card.paths.length > 0 ? `Paths: ${card.paths.join(", ")}` : undefined,
45
- card.plan === undefined ? undefined : `Plan: ${card.plan}`,
46
+ card.planPath === undefined ? undefined : `Plan: ${card.planPath}`,
46
47
  note === undefined ? undefined : `Note: ${note}`,
47
- `It is already assigned to you — record the plan with board action "plan" if it has none, then note each phase and move it to done or blocked.`,
48
+ `It is already assigned to you — write its contract with board action "contract" if it has none, then note each phase and move it to execute, blocked, or done.`,
48
49
  ].filter((line): line is string => line !== undefined);
49
50
  return withinLimit(lines.join("\n"));
50
51
  }
@@ -60,9 +61,9 @@ export function offerText(card: Card, note?: string): string {
60
61
  const lines = [
61
62
  `Offer: ${cardRef(card.id)} "${card.title}" is unclaimed.`,
62
63
  card.paths.length > 0 ? `Paths: ${card.paths.join(", ")}` : undefined,
63
- card.plan === undefined ? undefined : `Plan: ${card.plan}`,
64
+ card.planPath === undefined ? undefined : `Plan: ${card.planPath}`,
64
65
  note === undefined ? undefined : `Note: ${note}`,
65
- `First to claim it takes it: run board action "claim" with card "${card.id}", then note each phase and move it to done or blocked.`,
66
+ `First to claim it takes it: run board action "claim" with card "${card.id}", then write its contract and move it on from plan.`,
66
67
  ].filter((line): line is string => line !== undefined);
67
68
  return withinLimit(lines.join("\n"));
68
69
  }
@@ -123,6 +124,19 @@ export function notListening(peers: readonly Peer[] | undefined, labels: readonl
123
124
  });
124
125
  }
125
126
 
127
+ /**
128
+ * The actor a handoff names: the peer the bus lists, else the label that was asked for.
129
+ *
130
+ * `team assign` checks the card against this before it sends anything and records the same
131
+ * actor afterwards, so the check and the write cannot disagree about who `to` is. That is
132
+ * what makes an id the holder: a card records the id the bus gave it, `holds` compares ids
133
+ * first, and only the resolved actor carries one.
134
+ */
135
+ export function actorFor(peers: readonly Peer[] | undefined, to: string): Actor {
136
+ const peer = peerFor(peers, to);
137
+ return peer === undefined ? { label: to.trim() } : { id: peer.id, label: peer.label };
138
+ }
139
+
126
140
  /**
127
141
  * The peer a handoff names, as the card records it when the bus cannot say who that
128
142
  * is: the label a message file was written for, else the one that was asked for.
@@ -113,7 +113,22 @@ export function formatRacedAssignment(card: Card, to: string, reason: string): s
113
113
  );
114
114
  }
115
115
 
116
- /** A card taken back to To do. */
116
+ /** A card taken back to the pool. */
117
117
  export function formatReclaimed(card: Card, previous: string): string {
118
- return `${cardRef(card.id)} reclaimed from ${previous} and back in To do; its notes are kept.\n ${card.title}`;
118
+ return `${cardRef(card.id)} reclaimed from ${previous} and back in Backlog; its notes are kept.\n ${card.title}`;
119
+ }
120
+
121
+ /**
122
+ * A card the lead took itself.
123
+ *
124
+ * The next step is the board's, so it is named: a claim is the one round action that
125
+ * leaves a card in `plan` waiting on its owner, and a line that stopped at "yours" would
126
+ * leave the lead holding a card nobody said what to do with.
127
+ */
128
+ export function formatClaimed(card: Card): string {
129
+ return (
130
+ `${cardRef(card.id)} is yours, in plan.\n` +
131
+ ` ${card.title}\n` +
132
+ ` next: design it in plan mode, \`plan\` action \`write\`, then \`board\` action \`contract\``
133
+ );
119
134
  }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * team — the lead's round over the shared board.
3
3
  *
4
- * `board` owns the cards and never touches the session bus; `team` owns the two
5
- * actions that need it. `assign` hands a card to a session that is already running
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 is gone or has gone quiet.
4
+ * `board` owns the cards; `team` owns the leads round over them. `assign` hands a card to
5
+ * a session that is already running and sends it a handoff — dispatch cannot start a
6
+ * peer's turn, and the page and the docs say so — `offer` broadcasts one to whoever is
7
+ * free, `reclaim` takes one back when its owner is gone or has gone quiet, and `claim`
8
+ * takes one for the lead itself. The card's life after that is `board`'s.
8
9
  * Its one
9
10
  * command, `/team`, prints the round the board's Team tab draws, without a browser.
10
11
  *
11
- * Either extension works without the other: `board` is the work list any session
12
- * uses, and `team` is the lead's. The factory only registers; the store is opened on
13
- * first use.
12
+ * Either extension works without the other: `board` is the work list, and `team` is
13
+ * the lead's half of it; both are opt-in, so a session names the ones it runs. The
14
+ * factory only registers; the store is opened on first use.
14
15
  *
15
- * Load with: pi --extension ./extensions/team --extension ./extensions/session-bus
16
+ * Load with: PI_ENABLED_EXTENSIONS=board,team pi --extension ./extensions/team --extension ./extensions/session-bus
16
17
  */
17
18
 
18
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The `team` tool's parameters: two actions over one card.
2
+ * The `team` tool's parameters: four actions over one card.
3
3
  *
4
- * `assign` names the peer a handoff goes to, and `reclaim` takes a card back. Both
5
- * take the card the way `board` does — `4` or `#4` — because a lead reads ids off
6
- * the same board.
4
+ * `assign` names the peer a handoff goes to, and `reclaim` takes a card back; `claim`
5
+ * takes one for the lead. All of them take the card the way `board` does — `4` or `#4` —
6
+ * because a lead reads ids off the same board.
7
7
  */
8
8
 
9
9
  import { Type } from "typebox";
@@ -16,7 +16,8 @@ export const TeamParams = Type.Object({
16
16
  description:
17
17
  "`assign` hands a card to a running session and sends it a handoff; `offer` broadcasts an unclaimed card to " +
18
18
  "every other live session and writes nothing, so the first to claim it takes it; `reclaim` takes a card back " +
19
- "to To do. A lead's three actions — use `board` to create, claim, move, note, and finish the cards themselves.",
19
+ "to Backlog; `claim` takes one for the lead itself, which then plans and records it with `board`. They are " +
20
+ "the round's four verbs: `board` creates, plans, moves, and finishes the cards, and the page is a view.",
20
21
  },
21
22
  ),
22
23
  card: Type.String({ description: 'The card to act on, e.g. "4" or "#4".' }),
@@ -1,32 +1,45 @@
1
1
  /**
2
2
  * The `team` tool: the lead's round over the same cards `board` owns.
3
3
  *
4
- * Two actions, because those are the two that need the bus. `assign` sends the
5
- * handoff **first** and sets the owner **second**: a card assigned without a message
6
- * is a card nobody knows about, while a message for a card that then changed is one
7
- * the peer can still claim. Either failure is reported with the card as it actually
8
- * is, so the lead's next call starts from the truth.
4
+ * Four actions, because they are the four a lead takes at the round: hand a card out,
5
+ * offer it, take one back, and take one itself. `assign` sends the handoff **first** and
6
+ * sets the owner **second**: a card assigned without a message is a card nobody knows
7
+ * about, while a message for a card that then changed is one the peer can still claim.
8
+ * Either failure is reported with the card as it actually is, so the lead's next call
9
+ * starts from the truth.
9
10
  *
10
- * `reclaim` needs no message — it is the compensation for a dispatch that did not
11
- * land — and it belongs here rather than in `board` because it only makes sense in
12
- * the lead's context.
11
+ * `claim` is the lead's own work, and it is the same store operation `board`'s claim
12
+ * makes, so the lane rules, the WIP limit, and the offer record cleared are the store's —
13
+ * not a second answer the round would keep in step by hand. Everything after the claim
14
+ * (plan, contract, move, finish) is `board`'s, because that is the card's life and not
15
+ * the round's.
13
16
  */
14
17
 
15
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
19
  import type { Static } from "typebox";
17
20
  import { type Card, cardRef, normalizeCardId } from "../../lib/board/card.ts";
18
21
  import { indexOf } from "../../lib/board/links.ts";
19
- import { type Actor, applyOp } from "../../lib/board/rules.ts";
22
+ import { type Actor, mayOp } from "../../lib/board/rules.ts";
20
23
  import type { BoardSession } from "../../lib/board/session.ts";
21
24
  import type { BoardStore } from "../../lib/board/store.ts";
22
25
  import { messageOf } from "../../lib/errors.ts";
26
+ import type { TeamJoin } from "../../lib/session-tool.ts";
23
27
  import { failedResult, type ToolFailure, textResult } from "../../lib/tool-result.ts";
24
28
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
25
29
  import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
26
30
  import { plural } from "../../lib/board/round-text.ts";
27
31
  import { strayField } from "../../lib/stray-fields.ts";
28
- import { type Dispatch, handoffText, notListening, offerText, ownerFor, peerFor, sessionDispatch } from "./dispatch.ts";
29
32
  import {
33
+ actorFor,
34
+ type Dispatch,
35
+ handoffText,
36
+ notListening,
37
+ offerText,
38
+ ownerFor,
39
+ sessionDispatch,
40
+ } from "./dispatch.ts";
41
+ import {
42
+ formatClaimed,
30
43
  formatHandoff,
31
44
  formatHandoffFailure,
32
45
  formatOffer,
@@ -64,10 +77,19 @@ function callSubject(action: TeamAction, args: Record<string, unknown>): string
64
77
  export interface TeamToolsDeps {
65
78
  /** Test seam: the handoff transport. */
66
79
  dispatch?: (ctx: Parameters<typeof sessionDispatch>[0]) => Dispatch;
80
+ /** Test seam: the join a claim makes, so a suite records it instead of asking a bus. */
81
+ team?: TeamJoin;
67
82
  }
68
83
 
69
84
  export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: TeamToolsDeps = {}): void {
70
85
  const dispatchFor = deps.dispatch ?? sessionDispatch;
86
+ /** The join, built on the first claim: a round that never takes a card pays nothing. */
87
+ let joinBuilder: Promise<TeamJoin> | undefined;
88
+ const teamFor = (): Promise<TeamJoin> => {
89
+ if (deps.team !== undefined) return Promise.resolve(deps.team);
90
+ joinBuilder ??= import("../../lib/session-tool.ts").then(({ createTeamJoin }) => createTeamJoin(pi));
91
+ return joinBuilder;
92
+ };
71
93
 
72
94
  pi.registerTool({
73
95
  name: TEAM_TOOL,
@@ -75,13 +97,16 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
75
97
  description:
76
98
  "Run a round of work over the shared board: `assign` hands a card to a running session and sends it a handoff " +
77
99
  "(`to` is a teammate's label or id, as `/team` lists them); `offer` broadcasts an unclaimed card to every other " +
78
- "live session and writes nothing, so the first to claim it takes it; `reclaim` takes a card back to To do and " +
79
- "sends nothing, so it needs no team. Use `board` for everything else. Nothing here starts a peer's turn: a " +
80
- "message waits in its inbox, and a peer that is listening reports it at its next turn.",
81
- promptSnippet: "Run a round over the board: assign a card to a running session, offer one, or reclaim one.",
100
+ "live session and writes nothing, so the first to claim it takes it; `reclaim` takes a card back to Backlog and " +
101
+ "sends nothing, so it needs no team; `claim` takes one for yourself, and the card's plan, contract, moves, and " +
102
+ "finish are `board`'s. Nothing here starts a peer's turn: a message waits in its inbox, and a peer that is " +
103
+ "listening reports it at its next turn.",
104
+ promptSnippet:
105
+ "Run a round over the board: assign a card to a running session, offer one, reclaim one, or claim one.",
82
106
  promptGuidelines: [
83
107
  "Open the board with `/board` or work a card first, so this session and its peers are on the team.",
84
- "Offer a card nobody has taken; assign one you mean a particular peer to hold.",
108
+ "Offer a card nobody has taken; assign one you mean a particular peer to hold; claim one you will take " +
109
+ "yourself, then plan and record it with `board`.",
85
110
  "When a result says `Not listening`, say so: the message waits for that peer's next turn.",
86
111
  "Reclaim a card whose owner is gone or has gone quiet rather than editing someone else's work. A " +
87
112
  "`gone:` owner's process is not running, so what it held is anyone's to reclaim.",
@@ -102,12 +127,13 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
102
127
  if (stray !== undefined) return refusal(action, stray);
103
128
  const id = normalizeCardId(args.card);
104
129
  if (id === undefined) return refusal(action, `"${String(args.card)}" is not a card id.`);
105
- const me = session.who(ctx, ctx.cwd);
130
+ const me = await session.who(ctx, ctx.cwd);
106
131
  try {
107
132
  const store = await session.store(ctx.cwd);
108
133
  const now = session.now();
109
134
  if (action === "reclaim") return await reclaimCard(store, me, id, now);
110
135
  if (action === "offer") return await offerCard(store, me, id, args.note, now, dispatchFor(ctx));
136
+ if (action === "claim") return await claimCard(store, session, teamFor, ctx, me, id, now);
111
137
  const to = typeof args.to === "string" ? args.to.trim() : "";
112
138
  if (to === "") return refusal("assign", 'assign needs a peer: pass `to`, e.g. "billing-core".');
113
139
  return await assignCard(store, me, id, to, args.note, now, dispatchFor(ctx));
@@ -134,8 +160,9 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
134
160
  /**
135
161
  * `assign`: check the preconditions, send the handoff, then set the owner.
136
162
  *
137
- * The preconditions are checked with the same `applyOp` the store will use, so an
138
- * assignment the rules would refuse never sends a message at all.
163
+ * The preconditions are checked with `mayOp`, the same decision the store's `applyOp` will
164
+ * make — and with the store's own limits, so a configured WIP cap is the one the precheck
165
+ * sees — so an assignment the rules would refuse never sends a message at all.
139
166
  */
140
167
  async function assignCard(
141
168
  store: BoardStore,
@@ -148,11 +175,21 @@ async function assignCard(
148
175
  ): Promise<ReturnType<typeof textResult<TeamDetails>> | ToolFailure<TeamDetails>> {
149
176
  const card = await store.read(id);
150
177
  if (!card) return refusal("assign", `${cardRef(id)} does not exist.`);
151
- const owner: Actor = { label: to };
152
- // The board, not just the card: a card that waits on unfinished work is refused here,
153
- // so a handoff for it is never sent.
154
- const dry = applyOp(card, { kind: "assign", to: owner }, me, now, indexOf(await store.list()));
155
- if (!dry.ok) return refusal("assign", dry.error, card);
178
+ // A handoff to yourself is not a claim: it writes a message into your own inbox and
179
+ // records you as the owner without the claim's next step. `claim` is that act.
180
+ if (namesSelf(me, to))
181
+ return refusal("assign", `${cardRef(id)} cannot be handed to yourself: \`claim\` takes one.`, card);
182
+ // Who `to` names, asked before the check and once: a card records the id the bus gave its
183
+ // holder, and `holds` compares ids first, so an id the caller typed has to be resolved to
184
+ // the peer it names rather than compared as a label. The same actor is what the write
185
+ // records, so the check and the write cannot disagree about who `to` is.
186
+ const peers = await dispatch.peers().catch(() => undefined);
187
+ const target = actorFor(peers, to);
188
+ // The board, not just the card: a card that waits on unfinished work, or a `plan` lane
189
+ // with no room, is refused here, so a handoff for it is never sent. `mayOp` is the
190
+ // store's own check without the write, and the board's settings are the store's, not a default.
191
+ const refused = mayOp(card, { kind: "assign", to: target }, me, indexOf(await store.list()), store.config());
192
+ if (refused !== undefined) return refusal("assign", refused, card);
156
193
 
157
194
  let delivered: string[];
158
195
  try {
@@ -161,14 +198,12 @@ async function assignCard(
161
198
  return refusal("assign", formatHandoffFailure(card, to, messageOf(error)), card);
162
199
  }
163
200
 
164
- // Ask the bus which session answers to the label: an owner recorded with its id
165
- // survives the peer renaming itself, which a label alone does not — and the same read
166
- // says whether that peer will be woken by the message it just got.
167
- const peers = await dispatch.peers().catch(() => undefined);
168
- const peer = peerFor(peers, to);
201
+ // The owner a handoff records: the resolved peer, whose id survives the peer renaming
202
+ // itself, or the label a message file was written for when the bus could not say who that
203
+ // is. The same read also says whether that peer will be woken by the message it just got.
169
204
  const applied = await store.mutate(
170
205
  id,
171
- { kind: "assign", to: peer === undefined ? ownerFor(to, delivered) : { id: peer.id, label: peer.label } },
206
+ { kind: "assign", to: target.id === undefined ? ownerFor(to, delivered) : target },
172
207
  me,
173
208
  now,
174
209
  );
@@ -184,15 +219,6 @@ async function assignCard(
184
219
  });
185
220
  }
186
221
 
187
- /**
188
- * The actor an offer's preconditions are judged as: nobody in particular.
189
- *
190
- * `claim` is what the offer asks a peer to do, so the dry run is a claim by someone who
191
- * holds nothing — the rules' own refusals decide whether a card may be offered at all,
192
- * without a second copy of the sentences.
193
- */
194
- const OFFER_CLAIMANT: Actor = { label: "*" };
195
-
196
222
  /**
197
223
  * `offer`: check the claim's preconditions, broadcast the card, then record the offer.
198
224
  *
@@ -212,10 +238,11 @@ async function offerCard(
212
238
  ): Promise<ReturnType<typeof textResult<TeamDetails>> | ToolFailure<TeamDetails>> {
213
239
  const card = await store.read(id);
214
240
  if (!card) return refusal("offer", `${cardRef(id)} does not exist.`);
215
- // The board, not just the card: a card that waits on unfinished work, or one somebody
216
- // holds, is refused here, so a broadcast for it is never sent.
217
- const dry = applyOp(card, { kind: "claim" }, OFFER_CLAIMANT, now, indexOf(await store.list()));
218
- if (!dry.ok) return refusal("offer", dry.error, card);
241
+ // The board, not just the card: a card that waits on unfinished work, one somebody
242
+ // holds, or a `plan` lane with no room is refused here, so a broadcast for it is never
243
+ // sent. This is the offer's own rule, asked without the write.
244
+ const refused = mayOp(card, { kind: "offer" }, me, indexOf(await store.list()), store.config());
245
+ if (refused !== undefined) return refusal("offer", refused, card);
219
246
 
220
247
  let delivered: string[];
221
248
  try {
@@ -237,7 +264,41 @@ async function offerCard(
237
264
  });
238
265
  }
239
266
 
240
- /** `reclaim`: the owner clears and the card returns to To do, notes and all. */
267
+ /** Whether `to` names this session: its id, or its label ignoring case. */
268
+ function namesSelf(me: Actor, to: string): boolean {
269
+ const target = to.trim();
270
+ return (me.id !== undefined && target === me.id) || target.toLowerCase() === me.label.toLowerCase();
271
+ }
272
+
273
+ /**
274
+ * `claim`: the lead takes a card itself, through the store operation `board`'s claim makes.
275
+ *
276
+ * The lane rules, the WIP limit, the waiting refusal, and the offer record cleared are the
277
+ * store's, so the two doors cannot answer them differently. The join is the claim's one
278
+ * side effect outside the card: taking work is what makes this session a teammate, and it
279
+ * is the same ask-once call `board` makes.
280
+ */
281
+ async function claimCard(
282
+ store: BoardStore,
283
+ session: BoardSession,
284
+ teamFor: () => Promise<TeamJoin>,
285
+ ctx: ExtensionContext,
286
+ me: Actor,
287
+ id: string,
288
+ now: number,
289
+ ): Promise<ReturnType<typeof textResult<TeamDetails>> | ToolFailure<TeamDetails>> {
290
+ const before = await store.read(id);
291
+ if (!before) return refusal("claim", `${cardRef(id)} does not exist.`);
292
+ const result = await store.mutate(id, { kind: "claim" }, me, now);
293
+ if (!result.ok) return refusal("claim", result.error, result.card ?? before);
294
+ // Both loaded here, not at registration: a round that never takes a card pays nothing
295
+ // for the bus, and the registration graph stays the size it was.
296
+ const { joinOnce } = await import("../../lib/board/join.ts");
297
+ await joinOnce(session.team, await teamFor(), ctx, me.label);
298
+ return textResult(formatClaimed(result.card), { action: "claim", card: result.card });
299
+ }
300
+
301
+ /** `reclaim`: the owner clears and the card returns to the pool, notes and all. */
241
302
  async function reclaimCard(
242
303
  store: BoardStore,
243
304
  me: Actor,
@@ -257,11 +318,12 @@ async function reclaimCard(
257
318
  function summaryOf(details: TeamDetails): string {
258
319
  const card = details.card;
259
320
  if (!card) return details.action === "reclaim" ? "reclaimed" : "handed off";
321
+ if (details.action === "claim") return `${cardRef(card.id)} claimed`;
260
322
  if (details.action === "offer") {
261
323
  const count = details.delivered?.length ?? 0;
262
324
  return `${cardRef(card.id)} offered to ${plural(count, "session")}`;
263
325
  }
264
326
  const who = details.assigned?.label ?? card.owner?.label;
265
327
  const target = details.action === "assign" && who !== undefined ? ` → ${who}` : "";
266
- return `${cardRef(card.id)} ${card.status}${target}`;
328
+ return `${cardRef(card.id)} ${card.lane}${target}`;
267
329
  }
@@ -13,8 +13,8 @@ import type { CardOwner } from "../../lib/board/card.ts";
13
13
  /** The tool's name. `board` owns the cards; `team` owns the round. */
14
14
  export const TEAM_TOOL = "team";
15
15
 
16
- /** The three actions: the lead's whole surface — hand out, offer, take back. */
17
- export const TEAM_ACTIONS = ["assign", "offer", "reclaim"] as const;
16
+ /** The four actions: the lead's round — hand out, offer, take back, and take one itself. */
17
+ export const TEAM_ACTIONS = ["assign", "offer", "reclaim", "claim"] as const;
18
18
 
19
19
  /** One action. */
20
20
  export type TeamAction = (typeof TEAM_ACTIONS)[number];
@@ -57,6 +57,7 @@ export const ACTION_FIELDS: Record<TeamAction, readonly string[]> = {
57
57
  assign: ["card", "to", "note"],
58
58
  offer: ["card", "note"],
59
59
  reclaim: ["card"],
60
+ claim: ["card"],
60
61
  };
61
62
 
62
63
  /** Whether a value is one of the actions, so an unknown one is a refusal and not a crash. */
@@ -3,10 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { withRailsSuppressed } from "../../lib/rails.ts";
7
6
  import { formatTodoList, progressSummary } from "./format.ts";
8
7
  import type { TodoRuntime } from "./runtime.ts";
9
- import { TodoListComponent } from "./tui.ts";
10
8
 
11
9
  export function registerCommands(pi: ExtensionAPI, runtime: TodoRuntime): void {
12
10
  pi.registerCommand("todos", {
@@ -17,6 +15,10 @@ export function registerCommands(pi: ExtensionAPI, runtime: TodoRuntime): void {
17
15
  ctx.ui.notify(todos.length === 0 ? "No todos." : `${progressSummary(todos)}\n${formatTodoList(todos)}`, "info");
18
16
  return;
19
17
  }
18
+ // The screen and its rails wrapper are the TUI's: the listing above and
19
+ // registration load neither.
20
+ const { withRailsSuppressed } = await import("../../lib/rails.ts");
21
+ const { TodoListComponent } = await import("./view/todo-screen.ts");
20
22
  let unsubscribe: (() => void) | undefined;
21
23
  try {
22
24
  await withRailsSuppressed(pi, () =>
@@ -5,11 +5,22 @@
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
7
  import { GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
8
+ import { listRow } from "../../lib/tui/rows.ts";
8
9
  import { overflowHint } from "../../lib/tui/tool.ts";
9
10
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
10
11
  import { completedCount, currentTodo } from "./state.ts";
11
12
  import type { Todo, TodoStatus } from "./types.ts";
12
13
 
14
+ /** One indented item line, clipped to `width`. */
15
+ export function todoRow(todo: Todo, theme: Theme, width: number): string {
16
+ return listRow(theme, width, {
17
+ glyph: todoGlyphChar(todo),
18
+ glyphRole: todoGlyphRole(todo),
19
+ label: todoLabelText(todo),
20
+ labelRole: todoLabelRole(todo),
21
+ });
22
+ }
23
+
13
24
  /** Sort order so the widget and transcript surface current work before finished rows. */
14
25
  const STATUS_ORDER: Record<TodoStatus, number> = { in_progress: 0, pending: 1, completed: 2 };
15
26
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `TodoResult` transcript block: the indented glyph rail over the todo list.
3
+ *
4
+ * `render` runs on every frame Pi draws, for every block in the transcript, so
5
+ * the rail is memoized on the width (`RenderCache`) and rebuilt only when
6
+ * `setInput` hands over a new list or `invalidate` says the theme moved.
7
+ *
8
+ * This module is on the registration path — the `todo` tool's `renderResult`
9
+ * needs the class — while the `/todos` screen beside it is not.
10
+ */
11
+
12
+ import type { Theme } from "@earendil-works/pi-coding-agent";
13
+ import type { Component } from "@earendil-works/pi-tui";
14
+ import { RenderCache } from "../../lib/tui/render-cache.ts";
15
+ import { todoRailLines, type TodoRailInput } from "./format.ts";
16
+
17
+ /**
18
+ * Transcript result block: the indented glyph rail, wrapping at the render width.
19
+ *
20
+ * `render` runs on every frame Pi draws, for every block in the transcript, so the
21
+ * rail is memoized on the width (`RenderCache`) and rebuilt only when
22
+ * `setInput` hands over a new list or `invalidate` says the theme moved. The
23
+ * caller must go through `setInput`: mutating a list in place would leave the
24
+ * block showing what it drew last.
25
+ */
26
+ export class TodoResult implements Component {
27
+ private input: TodoRailInput;
28
+ private theme: Theme;
29
+ private readonly cache = new RenderCache();
30
+
31
+ constructor(input: TodoRailInput, theme: Theme) {
32
+ this.input = input;
33
+ this.theme = theme;
34
+ }
35
+
36
+ /** Update in place so the transcript can reuse this component across renders. */
37
+ setInput(input: TodoRailInput, theme: Theme): void {
38
+ this.input = input;
39
+ this.theme = theme;
40
+ this.cache.invalidate();
41
+ }
42
+
43
+ invalidate(): void {
44
+ this.cache.invalidate();
45
+ }
46
+
47
+ render(width: number): string[] {
48
+ const w = Math.max(1, width);
49
+ return this.cache.lines(w, undefined, () => this.build(w));
50
+ }
51
+
52
+ private build(w: number): string[] {
53
+ // The leading blank line separates the rail from the call header, matching
54
+ // the built-ins' header/output gap.
55
+ return ["", ...todoRailLines(this.input, this.theme, w)];
56
+ }
57
+ }