@gavin-hu/my-pi-agent 0.18.0 → 0.19.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 (249) hide show
  1. package/README.md +52 -44
  2. package/README.zh-CN.md +46 -41
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +554 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +61 -50
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +258 -162
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/panel/commands.ts +40 -7
  51. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  52. package/extensions/bytefly/panel/settings.ts +3 -20
  53. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  54. package/extensions/bytefly/picker/discovery.ts +0 -2
  55. package/extensions/bytefly/picker/types.ts +0 -2
  56. package/extensions/bytefly/update/check.ts +39 -0
  57. package/extensions/bytefly/update/format.ts +15 -0
  58. package/extensions/bytefly/update/register.ts +73 -34
  59. package/extensions/bytefly/update/state.ts +6 -3
  60. package/extensions/context/tui.ts +1 -1
  61. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  62. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  63. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  64. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  65. package/extensions/file-browse/index.ts +4 -4
  66. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  67. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
  68. package/extensions/file-browse/transport/server.ts +110 -0
  69. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  70. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  71. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  72. package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
  73. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  74. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  75. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  76. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  77. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  78. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  79. package/extensions/file-read/schema.ts +2 -1
  80. package/extensions/memory/store.ts +12 -5
  81. package/extensions/plan/blocks.ts +23 -2
  82. package/extensions/plan/commands.ts +21 -16
  83. package/extensions/plan/feedback.ts +147 -38
  84. package/extensions/plan/index.ts +6 -4
  85. package/extensions/plan/plan-index.ts +43 -9
  86. package/extensions/plan/plan-text.ts +33 -23
  87. package/extensions/plan/plans.ts +8 -11
  88. package/extensions/plan/policy.ts +20 -12
  89. package/extensions/plan/review.ts +3 -27
  90. package/extensions/plan/runtime.ts +21 -9
  91. package/extensions/plan/sections.ts +112 -0
  92. package/extensions/plan/steps.ts +3 -22
  93. package/extensions/plan/tools/exit.ts +11 -95
  94. package/extensions/plan/tui.ts +5 -34
  95. package/extensions/plan/types.ts +0 -8
  96. package/extensions/plan/web-client.ts +1 -1
  97. package/extensions/plan/web-page.ts +0 -7
  98. package/extensions/plan/web-router.ts +3 -57
  99. package/extensions/plan/web-share-session.ts +24 -30
  100. package/extensions/plan/web-streams.ts +14 -14
  101. package/extensions/plan/web-tokens.ts +2 -2
  102. package/extensions/plan/web.ts +66 -120
  103. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  104. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  105. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  106. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  107. package/extensions/rewind/index.ts +3 -3
  108. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  109. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  110. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  111. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  112. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  113. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  114. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  115. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  116. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  117. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  118. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  119. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  120. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  121. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  122. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  123. package/extensions/session-bus/commands.ts +17 -69
  124. package/extensions/session-bus/format.ts +22 -45
  125. package/extensions/session-bus/index.ts +13 -7
  126. package/extensions/session-bus/join-request.ts +40 -0
  127. package/extensions/session-bus/mail-watch.ts +134 -0
  128. package/extensions/session-bus/notice.ts +32 -4
  129. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  130. package/extensions/session-bus/protocol.ts +19 -49
  131. package/extensions/session-bus/resolve.ts +67 -0
  132. package/extensions/session-bus/result.ts +16 -51
  133. package/extensions/session-bus/rows.ts +4 -2
  134. package/extensions/session-bus/runtime.ts +129 -238
  135. package/extensions/session-bus/store.ts +42 -6
  136. package/extensions/session-bus/tools.ts +31 -66
  137. package/extensions/session-bus/tui.ts +4 -4
  138. package/extensions/session-bus/types.ts +13 -13
  139. package/extensions/subagent/agents.ts +9 -8
  140. package/extensions/subagent/invocation.ts +4 -2
  141. package/extensions/team/commands.ts +112 -0
  142. package/extensions/team/dispatch.ts +133 -0
  143. package/extensions/team/format.ts +105 -0
  144. package/extensions/team/index.ts +33 -0
  145. package/extensions/team/schema.ts +29 -0
  146. package/extensions/team/tools.ts +246 -0
  147. package/extensions/team/types.ts +48 -0
  148. package/extensions/web-access/fetch/page.ts +4 -1
  149. package/extensions/web-access/fetch/structured.ts +447 -0
  150. package/extensions/web-access/fetch/tool.ts +2 -1
  151. package/extensions/web-access/search/tool.ts +2 -1
  152. package/extensions/wechat/bridge.ts +76 -92
  153. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  154. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  155. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  156. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  157. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  158. package/extensions/wechat/dialog/channels.ts +136 -0
  159. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  160. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  161. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  162. package/extensions/wechat/index.ts +10 -10
  163. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  164. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  165. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  166. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  167. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  168. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  169. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  170. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  171. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  172. package/extensions/wechat/tools.ts +29 -12
  173. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  174. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  175. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  176. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  177. package/extensions/wechat/transport/outbound.ts +142 -0
  178. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  179. package/extensions/wechat/types.ts +10 -1
  180. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  181. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  182. package/extensions/worktree/guard.ts +3 -5
  183. package/lib/README.md +8 -6
  184. package/lib/board/card.ts +290 -0
  185. package/lib/board/links.ts +117 -0
  186. package/lib/board/page-pointer.ts +256 -0
  187. package/lib/board/round-text.ts +72 -0
  188. package/lib/board/round.ts +89 -0
  189. package/lib/board/rules.ts +304 -0
  190. package/lib/board/session.ts +105 -0
  191. package/lib/board/store.ts +310 -0
  192. package/lib/board/view.ts +150 -0
  193. package/lib/errors.ts +12 -4
  194. package/lib/format.ts +13 -0
  195. package/lib/http/guard.ts +112 -0
  196. package/lib/http/index.ts +16 -5
  197. package/lib/http/serve.ts +234 -0
  198. package/lib/http/server.ts +12 -10
  199. package/lib/http/sink.ts +110 -0
  200. package/lib/http/token.ts +38 -0
  201. package/lib/policy.ts +12 -2
  202. package/lib/requirements.ts +6 -7
  203. package/lib/session-label.ts +64 -0
  204. package/lib/session-roster.ts +130 -0
  205. package/lib/session-tool.ts +184 -0
  206. package/lib/tool-names.ts +25 -2
  207. package/lib/tui/glyphs.ts +2 -0
  208. package/lib/web/README.md +5 -2
  209. package/lib/web/components.ts +5 -0
  210. package/lib/web/runtime.ts +7 -1
  211. package/lib/web/tokens.ts +25 -2
  212. package/package.json +26 -14
  213. package/skills/next/SKILL.md +4 -3
  214. package/skills/project-docs/SKILL.md +133 -0
  215. package/skills/project-docs/references/audit.md +131 -0
  216. package/skills/project-docs/references/changelog.md +105 -0
  217. package/skills/project-docs/references/discovery.md +127 -0
  218. package/skills/project-docs/references/readme.md +80 -0
  219. package/skills/project-docs/references/release.md +84 -0
  220. package/skills/project-docs/references/roadmap.md +93 -0
  221. package/skills/project-docs/scripts/state.ts +316 -0
  222. package/skills/refactor/SKILL.md +100 -125
  223. package/skills/refactor/references/handoff.md +35 -0
  224. package/skills/refactor/references/signals.md +54 -0
  225. package/skills/refactor/scripts/inventory.ts +587 -0
  226. package/extensions/file-browse/README.md +0 -506
  227. package/extensions/file-browse/server.ts +0 -141
  228. package/extensions/plan/split.ts +0 -87
  229. package/extensions/session-bus/README.md +0 -247
  230. package/extensions/session-bus/team.ts +0 -102
  231. package/extensions/wechat/outbound.ts +0 -100
  232. package/lib/plans/sections.ts +0 -172
  233. package/lib/plans/setting.ts +0 -52
  234. package/lib/plans/split.ts +0 -215
  235. package/lib/plans/store.ts +0 -19
  236. package/lib/plans/team.ts +0 -151
  237. package/skills/changelog/SKILL.md +0 -148
  238. package/skills/parallel-dev/SKILL.md +0 -150
  239. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  240. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  241. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  242. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  243. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  244. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  245. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  246. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  247. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  248. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  249. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The `board` tool: the shared feature list for one repository.
3
+ *
4
+ * One action-shaped tool, because every action is work on the same record and a
5
+ * model that has read the board should not have to learn a second surface to change
6
+ * it. The tool owns nothing but the dispatch: the workspace comes from
7
+ * `lib/board/session.ts`, the transition from `lib/board/rules.ts`, the bytes from
8
+ * `lib/board/store.ts`, and the words from `format.ts` and `result.ts`.
9
+ *
10
+ * A refusal is a failed result that still carries the card, so the next call starts
11
+ * from the truth — and it is the same refusal the page shows, because both go
12
+ * through `applyOp`.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import type { Static } from "typebox";
17
+ import { cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
18
+ import { indexOf } from "../../lib/board/links.ts";
19
+ import { type CardOp, holds } from "../../lib/board/rules.ts";
20
+ import { buildRound } from "../../lib/board/round.ts";
21
+ import type { BoardStore } from "../../lib/board/store.ts";
22
+ import { messageOf } from "../../lib/errors.ts";
23
+ import { failedResult, textResult } from "../../lib/tool-result.ts";
24
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
25
+ import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
26
+ import { formatRound } from "../../lib/board/round-text.ts";
27
+ import { formatCard, formatCards, formatDropped } from "./format.ts";
28
+ import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
29
+ import { BoardResult, collapsedLine, resultInput } from "./result.ts";
30
+ import { createInput } from "./input.ts";
31
+ import type { BoardSession } from "../../lib/board/session.ts";
32
+ import { BoardParams } from "./schema.ts";
33
+ import { BOARD_ACTIONS, BOARD_TOOL, type BoardAction, type BoardDetails, normalizeBoardAction } from "./types.ts";
34
+
35
+ /** The parameters as the schema validates them. */
36
+ type BoardArgs = Static<typeof BoardParams>;
37
+
38
+ /**
39
+ * The call line's subject: the verb and what it acts on.
40
+ *
41
+ * `add` shows the title because the title is the identity of the thing being
42
+ * created; every other action names the card it touches, which is what a reader
43
+ * scanning the transcript is looking for.
44
+ */
45
+ function callSubject(action: BoardAction, args: Record<string, unknown>): string {
46
+ const card = typeof args.card === "string" && args.card.trim() !== "" ? cardRef(args.card.trim()) : "";
47
+ if (action === "add") {
48
+ const title = typeof args.title === "string" ? args.title.trim() : "";
49
+ return title === "" ? "add" : `add ${title}`;
50
+ }
51
+ if (action === "list") {
52
+ const parts = ["list"];
53
+ if (args.round === true) parts.push("round");
54
+ if (typeof args.status === "string") parts.push(args.status);
55
+ if (typeof args.query === "string" && args.query.trim() !== "") parts.push(`"${args.query.trim()}"`);
56
+ if (card !== "") parts.push(card);
57
+ return parts.join(" ");
58
+ }
59
+ if (card === "") return action;
60
+ const status = typeof args.status === "string" ? ` ${args.status}` : "";
61
+ return `${action} ${card}${status}`;
62
+ }
63
+
64
+ /** One refusal: the reason for the model, the state for the renderer. */
65
+ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, card?: BoardDetails["card"]) {
66
+ return failedResult(`Error: ${message}`, {
67
+ action,
68
+ me,
69
+ error: message,
70
+ ...(card === undefined ? {} : { card }),
71
+ });
72
+ }
73
+
74
+ /** The actions that need this session to be a teammate: everything but a read. */
75
+ const MUTATING_ACTIONS = new Set<BoardAction>(BOARD_ACTIONS.filter((action) => action !== "list"));
76
+
77
+ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { team?: TeamJoin } = {}): void {
78
+ const team = deps.team ?? createTeamJoin(pi);
79
+ pi.registerTool({
80
+ name: BOARD_TOOL,
81
+ label: "Board",
82
+ description:
83
+ "The shared feature list for this repository: one card per feature, which any session reads and writes. " +
84
+ "A card carries the feature's outcome, the session that holds it, path hints, the plan it is executing, " +
85
+ "its priority, what it waits for, and its notes. `list` shows the board (`status` filters one lane, " +
86
+ "`mine` shows only what this session holds, `query` matches a title, a path hint, or a holder, `card` shows one card, `round` adds the lead's rollup " +
87
+ "of owners, unassigned, unplanned, blocked, and idle - the rollup always covers the whole board). `add` " +
88
+ "creates a `todo` card; `claim` takes one for this session; `release` gives it back; `reclaim` takes it " +
89
+ "from whoever holds it; `move` changes lane; `note` appends to its history; `plan` records the plan file " +
90
+ "being executed; `edit` fixes what the card says (title, paths, priority, and the cards it waits for); " +
91
+ "`drop` deletes a `todo`, a `done`, or one you hold. A card's steps do not belong on the board - they " +
92
+ "belong in the plan and in `todo`.",
93
+ promptSnippet:
94
+ "The shared feature list for this repository: list, add, claim, release, reclaim, move, note, plan, edit, drop.",
95
+ promptGuidelines: [
96
+ "Claim a card before working on it; release or finish it when you stop, and reclaim a card whose owner is gone.",
97
+ "Record the plan file with `board` action `plan` once the plan is approved, then execute it.",
98
+ "Note each phase of the work, and move the card to `done` or `blocked` when it changes state.",
99
+ "A card that waits on another is linked with `edit` and `dependsOn`, so the order is on the board instead of in a refusal.",
100
+ "A feature's steps belong in `todo`, not on the board: one card is one feature, whatever it takes.",
101
+ "A `handoff` in your inbox is work you now own: read it with `session inbox`, record the plan, and start it.",
102
+ "`paths` are hints for a reader, not claims; `session`'s path claims are what protect a file.",
103
+ ],
104
+ parameters: BoardParams,
105
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
106
+ executionMode: "sequential",
107
+
108
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
109
+ const args = params as BoardArgs;
110
+ const action = normalizeBoardAction(args.action);
111
+ if (!action) {
112
+ return refusal(
113
+ "list",
114
+ { label: "board" },
115
+ `unknown action "${String(args.action)}"; use ${BOARD_ACTIONS.join(", ")}.`,
116
+ );
117
+ }
118
+ const me = runtime.who(ctx, ctx.cwd);
119
+ try {
120
+ // Working a card makes this session a teammate, once: the board is the user's
121
+ // surface for the team, so nobody has to know the bus exists. A failed join is
122
+ // not a failed card write — the board works with no bus at all — and it is not
123
+ // marked, so the next write asks again.
124
+ if (MUTATING_ACTIONS.has(action) && !runtime.joined()) {
125
+ if ((await team.join(ctx)).ok) runtime.markJoined();
126
+ }
127
+ const store = await runtime.store(ctx.cwd);
128
+ const now = runtime.now();
129
+ switch (action) {
130
+ case "list":
131
+ return await read(store, action, me, args, now);
132
+ case "add":
133
+ return await add(store, me, args, now);
134
+ case "claim":
135
+ return await mutate(store, action, me, now, args, { kind: "claim" });
136
+ case "release":
137
+ return await mutate(store, action, me, now, args, { kind: "release" });
138
+ case "reclaim":
139
+ return await mutate(store, action, me, now, args, { kind: "reclaim" });
140
+ case "move": {
141
+ if (args.card === undefined) return refusal(action, me, 'card is required: pass the id, e.g. "4".');
142
+ const status = args.status;
143
+ if (status === undefined || status === "todo") {
144
+ return refusal(
145
+ action,
146
+ me,
147
+ 'move needs a target lane: "doing", "blocked", or "done". Use release to return a card to todo.',
148
+ );
149
+ }
150
+ return await mutate(store, action, me, now, args, { kind: "move", status });
151
+ }
152
+ case "note":
153
+ return await mutate(store, action, me, now, args, { kind: "note", text: args.text });
154
+ case "plan":
155
+ return await mutate(store, action, me, now, args, { kind: "plan", path: args.path });
156
+ case "edit": {
157
+ if (
158
+ args.title === undefined &&
159
+ args.paths === undefined &&
160
+ args.priority === undefined &&
161
+ args.dependsOn === undefined
162
+ ) {
163
+ return refusal(action, me, "edit needs a title, paths, priority, or dependsOn.");
164
+ }
165
+ return await mutate(store, action, me, now, args, {
166
+ kind: "edit",
167
+ title: args.title,
168
+ paths: args.paths,
169
+ priority: args.priority,
170
+ dependsOn: args.dependsOn,
171
+ });
172
+ }
173
+ case "drop":
174
+ return await dropCard(store, me, args, now);
175
+ }
176
+ } catch (error) {
177
+ return refusal(action, me, messageOf(error));
178
+ }
179
+ },
180
+
181
+ renderCall(args, theme, context) {
182
+ const action = normalizeBoardAction(args.action) ?? "list";
183
+ return callLine(theme, context.lastComponent, BOARD_TOOL, callSubject(action, args as Record<string, unknown>));
184
+ },
185
+
186
+ renderResult(result, options, theme, context) {
187
+ const text = reuseText(context?.lastComponent);
188
+ const details = toolDetails<BoardDetails>(result.details);
189
+ const outcome = detailsOrLine(theme, result, options.expanded, details);
190
+ if ("line" in outcome) {
191
+ text.setText(outcome.line);
192
+ return text;
193
+ }
194
+ const input = resultInput(outcome.details);
195
+ if (!input) {
196
+ text.setText(collapsedLine(theme, outcome.details));
197
+ return text;
198
+ }
199
+ const view =
200
+ context?.lastComponent instanceof BoardResult ? context.lastComponent : new BoardResult(input, theme);
201
+ view.setInput(input, theme);
202
+ return view;
203
+ },
204
+ });
205
+ }
206
+
207
+ /** `list`: the board, one card, or the lead's rollup. */
208
+ async function read(
209
+ store: BoardStore,
210
+ action: BoardAction,
211
+ me: BoardDetails["me"],
212
+ args: BoardArgs,
213
+ now: number,
214
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
215
+ const cards = await store.list();
216
+ if (args.card !== undefined) {
217
+ const id = normalizeCardId(args.card);
218
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
219
+ const card = cards.find((entry) => entry.id === id);
220
+ if (!card) return refusal(action, me, `${cardRef(id)} does not exist.`);
221
+ return textResult(formatCard(card, indexOf(cards)), { action, me, card });
222
+ }
223
+ const listed = cards.filter(
224
+ (card) =>
225
+ (args.status === undefined || card.status === args.status) &&
226
+ (args.query === undefined || matchesQuery(card, args.query)) &&
227
+ // The same rule a claim is checked against, so "mine" cannot list a card this
228
+ // session could not release.
229
+ (args.mine !== true || holds(card, me)),
230
+ );
231
+ if (args.round !== true) return textResult(formatCards(listed), { action, me, cards: listed });
232
+ const round = buildRound(cards, now);
233
+ return textResult(formatRound(cards, round), { action, me, cards: listed, round });
234
+ }
235
+
236
+ /** `add`: the only action that creates a card. */
237
+ async function add(
238
+ store: BoardStore,
239
+ me: BoardDetails["me"],
240
+ args: BoardArgs,
241
+ now: number,
242
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
243
+ const parsed = createInput({ title: args.title, paths: args.paths, priority: args.priority, note: args.note });
244
+ if ("error" in parsed) return refusal("add", me, parsed.error);
245
+ const card = await store.create(parsed.input, me, now);
246
+ return textResult(formatCard(card), { action: "add", me, card });
247
+ }
248
+
249
+ /** The actions that change a card the caller names. */
250
+ async function mutate(
251
+ store: BoardStore,
252
+ action: BoardAction,
253
+ me: BoardDetails["me"],
254
+ now: number,
255
+ args: BoardArgs,
256
+ op: CardOp,
257
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
258
+ if (args.card === undefined) return refusal(action, me, 'card is required: pass the id, e.g. "4".');
259
+ const id = normalizeCardId(args.card);
260
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
261
+ const result = await store.mutate(id, op, me, now);
262
+ if (!result.ok) return refusal(action, me, result.error, result.card);
263
+ return textResult(formatCard(result.card), { action, me, card: result.card });
264
+ }
265
+
266
+ /** `drop`: the card and its notes leave together, so the report is the id. */
267
+ async function dropCard(
268
+ store: BoardStore,
269
+ me: BoardDetails["me"],
270
+ args: BoardArgs,
271
+ now: number,
272
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
273
+ if (args.card === undefined) return refusal("drop", me, 'card is required: pass the id, e.g. "4".');
274
+ const id = normalizeCardId(args.card);
275
+ if (id === undefined) return refusal("drop", me, `"${args.card}" is not a card id.`);
276
+ const result = await store.mutate(id, { kind: "drop" }, me, now);
277
+ if (!result.ok) return refusal("drop", me, result.error, result.card);
278
+ return textResult(formatDropped(id), { action: "drop", me, dropped: id });
279
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The board page's router: one request in, one response out, and every policy the
3
+ * served surface obeys decided here.
4
+ *
5
+ * The page can read and it can write, so it is checked the way `plan`'s review page
6
+ * is: a token in the URL, the `Host` allowlist, an origin check on every
7
+ * state-changing method, and a JSON body. The guards themselves are `lib/http`'s —
8
+ * this module only decides the order and the sentences.
9
+ *
10
+ * Nothing here knows what a board is. The snapshot arrives as `view()`, a write as
11
+ * `write()`, and the document as `page()`, all supplied by `page.ts`, so this file
12
+ * can be tested with a fake store and no server.
13
+ */
14
+
15
+ import {
16
+ envelopeRefusal,
17
+ eventHeaders,
18
+ jsonError,
19
+ jsonResponse,
20
+ pageHeaders,
21
+ PAGE_METHODS,
22
+ parseRequestUrl,
23
+ postOnly,
24
+ requireJsonBody,
25
+ scriptHeaders,
26
+ type ServeResponse,
27
+ } from "../../../lib/http/index.ts";
28
+ import type { BoardView } from "../../../lib/board/view.ts";
29
+ import type { Card } from "../../../lib/board/card.ts";
30
+ import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
31
+ import type { PageView } from "../view/page.ts";
32
+
33
+ /** The methods this surface serves: reads, writes, and the one deletion. */
34
+ const METHODS: readonly string[] = [...PAGE_METHODS, "DELETE"];
35
+
36
+ /**
37
+ * What a credential may do on this page.
38
+ *
39
+ * A page can be opened by the session that serves it and by a teammate watching it, and
40
+ * the two carry different tokens: `write` may change a card, `view` may only read. The
41
+ * split is a value here and not a boolean threaded through the page, so a route asks one
42
+ * function instead of remembering which token it was given.
43
+ */
44
+ export type Authority = "write" | "view";
45
+
46
+ /** The verbs a card route accepts, in the order the page uses them. */
47
+ const CARD_VERBS = ["claim", "release", "reclaim", "assign", "move", "note", "plan", "edit"] as const;
48
+
49
+ /** One card verb. */
50
+ export type CardVerb = (typeof CARD_VERBS)[number];
51
+
52
+ /** One write the page asks for, already routed but not yet applied. */
53
+ export type PageWrite =
54
+ | { kind: "add"; body: Record<string, unknown> }
55
+ | { kind: "drop"; id: string }
56
+ | { kind: CardVerb; id: string; body: Record<string, unknown> };
57
+
58
+ /** What a write did, or why it was refused with the status the page shows. */
59
+ export type PageResult = { ok: true; card: Card } | { ok: false; status: number; error: string; card?: Card };
60
+
61
+ /** Everything the router needs from the running page. */
62
+ export interface BoardRouterContext {
63
+ /** What the request's token may do here, or undefined when it is not a token of this page. */
64
+ authority: (given: string | null) => Authority | undefined;
65
+ /** The names this server answers to. */
66
+ hosts: () => readonly string[];
67
+ /** The document for one tab, as the authority that asked for it may see it. */
68
+ page: (view: PageView, authority: Authority) => Promise<string>;
69
+ /** The client script. */
70
+ script: string;
71
+ /** The snapshot the page renders, and the body of `/api/board`. */
72
+ view: () => Promise<BoardView>;
73
+ /** Apply one write as the serving session. */
74
+ write: (request: PageWrite) => Promise<PageResult>;
75
+ }
76
+
77
+ /**
78
+ * Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values;
79
+ * `body` is the request body, which is only non-empty when the server caps it.
80
+ */
81
+ export async function handleBoardRequest(
82
+ ctx: BoardRouterContext,
83
+ method: string,
84
+ rawUrl: string,
85
+ headers: Record<string, string | string[] | undefined> = {},
86
+ body = "",
87
+ ): Promise<ServeResponse> {
88
+ const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts(), allowed: METHODS });
89
+ if (envelope) return envelope;
90
+
91
+ const parsed = parseRequestUrl(rawUrl);
92
+ if (!parsed) return jsonError(400, "Malformed request URL.");
93
+ const { pathname, search } = parsed;
94
+
95
+ if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
96
+ if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
97
+ if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: ctx.script };
98
+
99
+ const authority = ctx.authority(search.get("token"));
100
+ if (authority === undefined) return jsonError(403, "Invalid or missing token.");
101
+
102
+ if (pathname === "/api/board") return jsonResponse(200, await ctx.view());
103
+ if (pathname === "/" || pathname === "/index.html") {
104
+ const view = viewOf(search.get("view"));
105
+ return { status: 200, headers: pageHeaders(), body: await ctx.page(view, authority) };
106
+ }
107
+ if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
108
+
109
+ // Everything below changes a card, so a credential that may only read stops here.
110
+ const readOnly = writeGuard(authority);
111
+ if (readOnly) return readOnly;
112
+
113
+ if (pathname === "/api/cards") {
114
+ const methodRefusal = postOnly(method, "add a card");
115
+ if (methodRefusal) return methodRefusal;
116
+ const jsonRefusal = requireJsonBody(headers["content-type"]);
117
+ if (jsonRefusal) return jsonRefusal;
118
+ const parsedBody = parseBody(body);
119
+ if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
120
+ return writeAnswer(await ctx.write({ kind: "add", body: parsedBody }));
121
+ }
122
+
123
+ const route = /^\/api\/cards\/([^/]+)(?:\/([^/]+))?$/.exec(pathname);
124
+ if (!route) return jsonError(404, "That page does not exist.");
125
+ const id = decodeURIComponent(route[1] ?? "");
126
+ const verb = route[2];
127
+
128
+ if (verb === undefined) {
129
+ if (method.toUpperCase() !== "DELETE") return jsonError(405, "Use DELETE to drop a card.");
130
+ return writeAnswer(await ctx.write({ kind: "drop", id }));
131
+ }
132
+ if (!CARD_VERBS.includes(verb as CardVerb)) return jsonError(404, "That card action does not exist.");
133
+ const methodRefusal = postOnly(method, verb);
134
+ if (methodRefusal) return methodRefusal;
135
+ const jsonRefusal = requireJsonBody(headers["content-type"]);
136
+ if (jsonRefusal) return jsonRefusal;
137
+ const parsedBody = parseBody(body);
138
+ if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
139
+ return writeAnswer(await ctx.write({ kind: verb as CardVerb, id, body: parsedBody }));
140
+ }
141
+
142
+ /** The 403 for a credential that may read this page but not change it. */
143
+ function writeGuard(authority: Authority): ServeResponse | undefined {
144
+ if (authority === "write") return undefined;
145
+ return jsonError(403, "This page is read-only for a viewer.");
146
+ }
147
+
148
+ /** The tab a `?view=` names; anything else is the board. */
149
+ function viewOf(raw: string | null): PageView {
150
+ return raw === "team" ? "team" : "board";
151
+ }
152
+
153
+ /** A request body as an object, or undefined when it is not one. */
154
+ function parseBody(body: string): Record<string, unknown> | undefined {
155
+ let parsed: unknown;
156
+ try {
157
+ parsed = JSON.parse(body);
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
162
+ return parsed as Record<string, unknown>;
163
+ }
164
+
165
+ /** A write's answer: the card, or the refusal and the card it left alone. */
166
+ function writeAnswer(result: PageResult): ServeResponse {
167
+ if (result.ok) return jsonResponse(200, { card: result.card });
168
+ return jsonResponse(
169
+ result.status,
170
+ result.card === undefined ? { error: result.error } : { error: result.error, card: result.card },
171
+ );
172
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The board page's server: one loopback listener, the snapshot pushed to every
3
+ * attached tab, and the idle grace that closes the page when the last one leaves.
4
+ *
5
+ * `lib/http` owns the socket — binding, the body cap, the SSE head, and the
6
+ * idempotent close — so this module owns only the board's own decisions: when a
7
+ * frame is pushed (only when a card's revision moved), who receives it, and what
8
+ * stops the ticker.
9
+ */
10
+
11
+ import {
12
+ type EventSink,
13
+ LOOPBACK_HOSTS,
14
+ type LoopbackServer,
15
+ mintToken,
16
+ serveLoopback,
17
+ tokenMatches,
18
+ } from "../../../lib/http/index.ts";
19
+ import { defaultInterval, type RepeatTimer } from "../../../lib/timer.ts";
20
+ import type { BoardView } from "../../../lib/board/view.ts";
21
+ import type { PageView } from "../view/page.ts";
22
+ import {
23
+ type Authority,
24
+ type BoardRouterContext,
25
+ handleBoardRequest,
26
+ type PageResult,
27
+ type PageWrite,
28
+ } from "./router.ts";
29
+
30
+ /** How often the page is offered a frame; a frame is sent only when the signature moved. */
31
+ export const PUSH_MS = 1_000;
32
+
33
+ /** Largest request body the page accepts: a card and a note are small. */
34
+ export const MAX_BODY_BYTES = 64 * 1_024;
35
+
36
+ /** What the page is built from. The lifecycle (`page.ts`) supplies all of it. */
37
+ export interface BoardServerOptions {
38
+ /** Ports tried in order: the session's remembered port, then an OS-chosen one. */
39
+ ports: readonly number[];
40
+ /** The credential that may change a card; minted here when the caller has none. */
41
+ token?: string;
42
+ /** The credential a viewer carries: every read, and no write. */
43
+ viewToken?: string;
44
+ page: (view: PageView, authority: Authority) => Promise<string>;
45
+ script: string;
46
+ view: () => Promise<BoardView>;
47
+ write: (request: PageWrite) => Promise<PageResult>;
48
+ /** Called when the last tab has been gone for the grace period. */
49
+ onIdle?: () => void;
50
+ /** Grace after the last stream leaves; 0 or absent waits for `close()`. */
51
+ idleMs?: number;
52
+ /** The interval seam, so a test drives the ticker instead of waiting. */
53
+ interval?: RepeatTimer;
54
+ }
55
+
56
+ /** A running page. */
57
+ export interface BoardServer {
58
+ port: number;
59
+ /** The URL the lead opens: the page, with the credential that may change it. */
60
+ url: string;
61
+ /** The URL a teammate opens: the same page, read-only. */
62
+ viewUrl: string;
63
+ /** How many tabs are attached, which is what `/board status` reports. */
64
+ watchers: () => number;
65
+ /** The signature of the last frame pushed, so a caller can tell it is live. */
66
+ pushed: () => string;
67
+ close: () => Promise<void>;
68
+ }
69
+
70
+ export async function createBoardServer(options: BoardServerOptions): Promise<BoardServer> {
71
+ const token = options.token ?? mintToken();
72
+ const viewToken = options.viewToken ?? mintToken();
73
+ const clients = new Set<EventSink>();
74
+ const interval = options.interval ?? defaultInterval;
75
+ let pushed = "";
76
+
77
+ const ctx: BoardRouterContext = {
78
+ // Two credentials, one page: the lead's may change a card, a teammate's may only
79
+ // read it. Which of the two a request carries is the whole authorisation story.
80
+ authority: (given) => {
81
+ const value = given ?? "";
82
+ if (tokenMatches(token, value)) return "write";
83
+ return tokenMatches(viewToken, value) ? "view" : undefined;
84
+ },
85
+ hosts: () => LOOPBACK_HOSTS,
86
+ page: options.page,
87
+ script: options.script,
88
+ view: options.view,
89
+ write: options.write,
90
+ };
91
+
92
+ const push = async (): Promise<void> => {
93
+ if (clients.size === 0) return;
94
+ const view = await options.view();
95
+ // A frame is a snapshot, so sending one that says nothing new costs the page a
96
+ // parse and every reader a flicker. The signature is what changes.
97
+ if (view.sig === pushed) return;
98
+ pushed = view.sig;
99
+ const frame = `data: ${JSON.stringify(view)}\n\n`;
100
+ for (const sink of clients) sink.write(frame);
101
+ };
102
+
103
+ const server: LoopbackServer = await serveLoopback({
104
+ ports: options.ports,
105
+ maxBodyBytes: MAX_BODY_BYTES,
106
+ ...(options.idleMs === undefined || options.onIdle === undefined
107
+ ? {}
108
+ : { idle: { ms: options.idleMs, onIdle: options.onIdle } }),
109
+ async handler(request) {
110
+ const result = await handleBoardRequest(ctx, request.method, request.url, request.headers, request.body);
111
+ if (!result.stream) return { result };
112
+ return {
113
+ result,
114
+ openSink(sink) {
115
+ clients.add(sink);
116
+ // A tab that just attached gets the current frame without waiting for a
117
+ // card to change — otherwise a quiet board looks like a dead page.
118
+ void options.view().then((view) => {
119
+ pushed = view.sig;
120
+ sink.write(`data: ${JSON.stringify(view)}\n\n`);
121
+ });
122
+ return () => {
123
+ clients.delete(sink);
124
+ };
125
+ },
126
+ };
127
+ },
128
+ });
129
+
130
+ const stopTicker = interval(PUSH_MS, () => void push());
131
+ let closed = false;
132
+ const close = async (): Promise<void> => {
133
+ if (closed) return;
134
+ closed = true;
135
+ stopTicker();
136
+ for (const sink of clients) sink.end();
137
+ clients.clear();
138
+ await server.close();
139
+ };
140
+
141
+ return {
142
+ port: server.port,
143
+ url: `http://127.0.0.1:${server.port}/?token=${token}`,
144
+ viewUrl: `http://127.0.0.1:${server.port}/?token=${viewToken}`,
145
+ watchers: () => clients.size,
146
+ pushed: () => pushed,
147
+ close,
148
+ };
149
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The shapes the `board` tool registers and reports.
3
+ *
4
+ * `details` is the result's state: the card an action produced, the cards a list
5
+ * read, the rollup when it was asked for, and — on a refusal — the card as it was,
6
+ * so a retry starts from the truth rather than from what the model thought it
7
+ * knew. The renderer and the tool share these types, and nothing else.
8
+ */
9
+
10
+ import type { Card } from "../../lib/board/card.ts";
11
+ import type { Actor } from "../../lib/board/rules.ts";
12
+ import type { RoundSummary } from "../../lib/board/round.ts";
13
+
14
+ /** The tool's name. One extension registers it, so it stays here. */
15
+ export const BOARD_TOOL = "board";
16
+
17
+ /** The actions the tool accepts, in the order the description lists them. */
18
+ export const BOARD_ACTIONS = [
19
+ "list",
20
+ "add",
21
+ "claim",
22
+ "release",
23
+ "reclaim",
24
+ "move",
25
+ "note",
26
+ "plan",
27
+ "edit",
28
+ "drop",
29
+ ] as const;
30
+
31
+ /** One action. */
32
+ export type BoardAction = (typeof BOARD_ACTIONS)[number];
33
+
34
+ /** A result the transcript renders and the page's routes mirror. */
35
+ export interface BoardDetails {
36
+ action: BoardAction;
37
+ /** The serving session: the id Pi knows, and the label a card records. */
38
+ me: Actor;
39
+ /** The board a `list` read, in lane order. */
40
+ cards?: Card[];
41
+ /** The card an action produced, or the one a refusal left untouched. */
42
+ card?: Card;
43
+ /** The lead's rollup, only for `list` with `round`. */
44
+ round?: RoundSummary;
45
+ /** The id a `drop` removed. */
46
+ dropped?: string;
47
+ /** A refusal's reason, which the renderer draws instead of the details. */
48
+ error?: string;
49
+ }
50
+
51
+ /** Whether a value is one of the actions, so an unknown one is a refusal and not a crash. */
52
+ export function normalizeBoardAction(raw: unknown): BoardAction | undefined {
53
+ return typeof raw === "string" && BOARD_ACTIONS.includes(raw as BoardAction) ? (raw as BoardAction) : undefined;
54
+ }