@gavin-hu/my-pi-agent 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
package/lib/board/card.ts CHANGED
@@ -30,12 +30,25 @@ export const MAX_PATH = 200;
30
30
  /** Longest recorded plan path, in characters. */
31
31
  export const MAX_PLAN = 300;
32
32
 
33
+ /** Longest sentence saying what a blocked card is waiting on: one line, so it fits a row. */
34
+ export const MAX_BLOCKED = 200;
35
+
33
36
  /** Longest single note, in characters. */
34
37
  export const MAX_NOTE = 1000;
35
38
 
36
39
  /** How many notes one card keeps; the oldest drops past this, counted in `notesDropped`. */
37
40
  export const MAX_NOTES = 8;
38
41
 
42
+ /**
43
+ * How many lane transitions one card keeps; the oldest drops past this, counted in
44
+ * `logDropped`.
45
+ *
46
+ * Bounded at write time rather than by a janitor, because nothing prunes a board: the
47
+ * log is the only record of when a lane was entered, so it has to stay small enough
48
+ * that every card can afford one.
49
+ */
50
+ export const MAX_LOG = 8;
51
+
39
52
  /** How many other cards one card may wait on. */
40
53
  export const MAX_DEPENDS = 6;
41
54
 
@@ -69,6 +82,33 @@ export interface CardNote {
69
82
  text: string;
70
83
  }
71
84
 
85
+ /**
86
+ * One lane a card has been in, when it got there, and who moved it.
87
+ *
88
+ * `updatedAt` moves on every write, so it answers "when was this last touched", not
89
+ * "how long has this been blocked": a note filed against a blocked card resets it. A
90
+ * transition is recorded only when the lane actually changes, which is the fact the
91
+ * page needs and the one a reader has no way to reconstruct.
92
+ */
93
+ export interface CardTransition {
94
+ status: CardStatus;
95
+ at: number;
96
+ by: string;
97
+ }
98
+
99
+ /**
100
+ * What a finished card landed as.
101
+ *
102
+ * A `done` card cannot say which change closed it from its own text, and `done` is
103
+ * terminal, so the commit is recorded once, where the move that finished it happens.
104
+ */
105
+ export interface CardLanded {
106
+ /** The commit at the move to `done`, as git resolves `HEAD` there. */
107
+ commit: string;
108
+ /** When the card was finished. */
109
+ at: number;
110
+ }
111
+
72
112
  /** One feature on the board. */
73
113
  export interface Card {
74
114
  /** Schema version; an unknown value is refused rather than guessed at. */
@@ -83,6 +123,25 @@ export interface Card {
83
123
  paths: string[];
84
124
  /** The plan file being executed, once one is recorded. */
85
125
  plan?: string;
126
+ /**
127
+ * What a blocked card is waiting on: the decision, in one line.
128
+ *
129
+ * `blocked` exists to say a human has to decide something — a dependency is a
130
+ * different thing, and `waits` is what refuses a claim. Absent means the card was
131
+ * blocked without saying why, which is a state the tool allows and the page names.
132
+ */
133
+ blockedBy?: string;
134
+ /** What closed a finished card: the commit at the move to `done`, and when. */
135
+ landed?: CardLanded;
136
+ /**
137
+ * When this card was last offered to the team, and nobody has taken it.
138
+ *
139
+ * An offer is a bus event, and an event leaves nothing behind: a card offered to three
140
+ * sessions that all ignored it read exactly like one nobody had looked at. This is the
141
+ * one fact an offer can leave, and every answer to it — a claim, an assignment, a
142
+ * release, a move — clears it, so a record here means the offer is still open.
143
+ */
144
+ offeredAt?: number;
86
145
  /** How urgent the feature is; absent reads as {@link DEFAULT_PRIORITY}. */
87
146
  priority?: Priority;
88
147
  /** Ids of the cards this one waits for; absent reads as none. */
@@ -91,6 +150,15 @@ export interface Card {
91
150
  notes: CardNote[];
92
151
  /** How many notes dropped off the front, so a reader knows history was elided. */
93
152
  notesDropped: number;
153
+ /**
154
+ * The lanes this card has been in, oldest first: the last {@link MAX_LOG} transitions.
155
+ *
156
+ * Absent on a card written before this field existed, which is why every reader goes
157
+ * through {@link laneSince} rather than reading the list directly.
158
+ */
159
+ log?: CardTransition[];
160
+ /** How many transitions dropped off the front, so a reader knows the log was elided. */
161
+ logDropped?: number;
94
162
  /** Bumped per write; the compare-and-set guard a concurrent writer loses on. */
95
163
  rev: number;
96
164
  createdAt: number;
@@ -99,6 +167,47 @@ export interface Card {
99
167
  by: string;
100
168
  }
101
169
 
170
+ /**
171
+ * What a card left behind when it was dropped: the record that its id is spent.
172
+ *
173
+ * A card's id is how a note, a handoff, or another card names one feature, so an id
174
+ * that comes back is worse than an id that is gone — the same number would answer for
175
+ * two different features. The store keeps one of these per ended card, writes it
176
+ * before the card file goes, and never allocates its id again.
177
+ */
178
+ export interface EndedCard {
179
+ /** Schema version for the record itself; a reader refuses what it does not know. */
180
+ v: 1;
181
+ /** The id that is now spent, without padding. */
182
+ id: string;
183
+ /** What the card was called, so a note that names its id still reads. */
184
+ title: string;
185
+ /** The lane it was in when it ended. */
186
+ status: CardStatus;
187
+ /** Who ended it. */
188
+ by: string;
189
+ /** The owner it had, when it had one. */
190
+ owner?: CardOwner;
191
+ /** When it ended. */
192
+ at: number;
193
+ /** Why it ended: the operation, which is `drop` today. */
194
+ why: "dropped";
195
+ }
196
+
197
+ /** Whether `value` is an ended-card record this build can trust. */
198
+ export function isEndedCard(value: unknown): value is EndedCard {
199
+ if (typeof value !== "object" || value === null) return false;
200
+ const ended = value as Record<string, unknown>;
201
+ if (ended.v !== CARD_VERSION) return false;
202
+ if (typeof ended.id !== "string" || !/^[0-9]+$/.test(ended.id)) return false;
203
+ if (typeof ended.title !== "string" || ended.title === "") return false;
204
+ if (!CARD_STATUSES.includes(ended.status as CardStatus)) return false;
205
+ if (typeof ended.by !== "string" || ended.by === "") return false;
206
+ if (!isOwner(ended.owner)) return false;
207
+ if (typeof ended.at !== "number" || !Number.isInteger(ended.at)) return false;
208
+ return ended.why === "dropped";
209
+ }
210
+
102
211
  /** A rejected field, phrased for the model: what was wrong, not that something was. */
103
212
  export type FieldError = { error: string };
104
213
 
@@ -163,25 +272,50 @@ export function dependsOf(card: Card): readonly string[] {
163
272
  return card.dependsOn ?? [];
164
273
  }
165
274
 
275
+ /**
276
+ * When the card entered the lane it is in now, or `undefined` when its history does not
277
+ * say.
278
+ *
279
+ * Two ways to know nothing: a card written before the log existed, and one whose entry
280
+ * for the current lane has dropped off the front of a full log. Both read the same to a
281
+ * caller — the page draws no age — rather than a guess from `updatedAt`, which is a
282
+ * different fact and would be wrong exactly when a card is being worked on.
283
+ */
284
+ export function laneSince(card: Card): number | undefined {
285
+ const last = card.log?.at(-1);
286
+ return last !== undefined && last.status === card.status ? last.at : undefined;
287
+ }
288
+
166
289
  /** Where a card's priority sits in {@link PRIORITIES}: the lane sort's first key after the lane. */
167
290
  export function priorityRank(card: Card): number {
168
291
  return PRIORITIES.indexOf(priorityOf(card));
169
292
  }
170
293
 
171
294
  /**
172
- * Whether `query` names `card`: its title, one of its path hints, or its holder.
295
+ * Whether `query` names `card`: its title, its holder, a path hint, its plan, or a note.
173
296
  *
174
297
  * The page's filter and `board list`'s `query` are the same question, so they are the
175
- * same rule here — a reader who finds a card in the browser finds it in the tool.
298
+ * same rule here — and the same *body*: this function's source is spliced into the
299
+ * browser bundle as `MATCHES_QUERY_JS`, which is why it is written with concatenation
300
+ * and no backtick. A second copy in the page is exactly what drifted before.
301
+ *
302
+ * The order is the answer's weight: a title hit is what a reader meant, then who holds
303
+ * it, then where it lives, and only then the reasoning — a plan path and the notes,
304
+ * which are the two fields that make a card findable by what it says.
176
305
  */
177
306
  export function matchesQuery(card: Card, query: string): boolean {
178
307
  const needle = query.trim().toLowerCase();
179
308
  if (needle === "") return true;
180
309
  if (card.title.toLowerCase().includes(needle)) return true;
181
310
  if (card.owner !== undefined && card.owner.label.toLowerCase().includes(needle)) return true;
182
- return card.paths.some((path) => path.toLowerCase().includes(needle));
311
+ if (card.paths.some((path) => path.toLowerCase().includes(needle))) return true;
312
+ if (card.plan !== undefined && card.plan.toLowerCase().includes(needle)) return true;
313
+ return card.notes.some((note) => note.text.toLowerCase().includes(needle));
183
314
  }
184
315
 
316
+ /** The same body, as the browser receives it: one rule for the tool and the filter box. */
317
+ export const MATCHES_QUERY_JS = String(matchesQuery);
318
+
185
319
  /** One note's text: a single sanitized line, or the reason it cannot be one. */
186
320
  export function normalizeNote(raw: unknown): { text: string } | FieldError {
187
321
  if (typeof raw !== "string") return { error: "text is required." };
@@ -205,6 +339,22 @@ export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
205
339
  return { path };
206
340
  }
207
341
 
342
+ /**
343
+ * The sentence a block records: one line, and nothing more.
344
+ *
345
+ * The board never resolves it against anything — it is the reader's explanation of what
346
+ * a person has to decide — so this only keeps it to a length a row can hold.
347
+ */
348
+ export function normalizeBlockedBy(raw: unknown): { blockedBy: string } | FieldError {
349
+ if (typeof raw !== "string") return { error: "blockedBy must be text." };
350
+ const blockedBy = sanitizeLine(raw).trim();
351
+ if (blockedBy === "") return { error: "blockedBy must say what the card waits on." };
352
+ if (blockedBy.length > MAX_BLOCKED) {
353
+ return { error: `blockedBy must be at most ${MAX_BLOCKED} characters.` };
354
+ }
355
+ return { blockedBy };
356
+ }
357
+
208
358
  /** A card id as a caller writes it (`4`, `#4`), or undefined when it is not one. */
209
359
  export function normalizeCardId(raw: unknown): string | undefined {
210
360
  if (typeof raw !== "string") return undefined;
@@ -250,17 +400,38 @@ export function isCard(value: unknown): value is Card {
250
400
  if (!Array.isArray(card.paths) || card.paths.length > MAX_PATHS) return false;
251
401
  if (!card.paths.every((path) => typeof path === "string")) return false;
252
402
  if (card.plan !== undefined && typeof card.plan !== "string") return false;
403
+ if (card.blockedBy !== undefined && !isBlockedBy(card.blockedBy)) return false;
404
+ if (card.landed !== undefined && !isLanded(card.landed)) return false;
405
+ if (card.offeredAt !== undefined && (typeof card.offeredAt !== "number" || !Number.isInteger(card.offeredAt))) {
406
+ return false;
407
+ }
253
408
  if (card.priority !== undefined && !PRIORITIES.includes(card.priority as Priority)) return false;
254
409
  if (card.dependsOn !== undefined && !isDepends(card.dependsOn)) return false;
255
410
  if (!Array.isArray(card.notes) || card.notes.length > MAX_NOTES) return false;
256
411
  if (!card.notes.every(isNote)) return false;
257
412
  if (typeof card.notesDropped !== "number") return false;
413
+ if (card.log !== undefined && (!Array.isArray(card.log) || card.log.length > MAX_LOG)) return false;
414
+ if (Array.isArray(card.log) && !card.log.every(isTransition)) return false;
415
+ if (card.logDropped !== undefined && !isCount(card.logDropped)) return false;
258
416
  if (typeof card.rev !== "number" || !Number.isInteger(card.rev) || card.rev < 1) return false;
259
417
  if (typeof card.createdAt !== "number" || typeof card.updatedAt !== "number") return false;
260
418
  if (typeof card.by !== "string") return false;
261
419
  return (card.status === "todo") === (card.owner === undefined);
262
420
  }
263
421
 
422
+ /** A recorded block: one line the reader can hold, within the cap this build writes. */
423
+ function isBlockedBy(value: unknown): value is string {
424
+ return typeof value === "string" && value !== "" && value.length <= MAX_BLOCKED;
425
+ }
426
+
427
+ /** A recorded landing: a commit id and the moment it closed the card. */
428
+ function isLanded(value: unknown): value is CardLanded {
429
+ if (typeof value !== "object" || value === null) return false;
430
+ const landed = value as Record<string, unknown>;
431
+ if (typeof landed.commit !== "string" || !/^[0-9a-f]{4,40}$/.test(landed.commit)) return false;
432
+ return typeof landed.at === "number" && Number.isInteger(landed.at);
433
+ }
434
+
264
435
  function isOwner(value: unknown): boolean {
265
436
  if (value === undefined) return true;
266
437
  if (typeof value !== "object" || value === null) return false;
@@ -283,6 +454,24 @@ function isDepends(value: unknown): boolean {
283
454
  return true;
284
455
  }
285
456
 
457
+ /** A recorded lane change: a lane, a moment, and who moved it. */
458
+ function isTransition(value: unknown): value is CardTransition {
459
+ if (typeof value !== "object" || value === null) return false;
460
+ const entry = value as Record<string, unknown>;
461
+ return (
462
+ CARD_STATUSES.includes(entry.status as CardStatus) &&
463
+ typeof entry.at === "number" &&
464
+ Number.isInteger(entry.at) &&
465
+ typeof entry.by === "string" &&
466
+ entry.by !== ""
467
+ );
468
+ }
469
+
470
+ /** A count a reader can trust: a whole number that never ran backwards past zero. */
471
+ function isCount(value: unknown): value is number {
472
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
473
+ }
474
+
286
475
  function isNote(value: unknown): boolean {
287
476
  if (typeof value !== "object" || value === null) return false;
288
477
  const note = value as Record<string, unknown>;
@@ -39,17 +39,41 @@ export function blockersOf(card: Card, board: BoardIndex): Card[] {
39
39
  .filter((other): other is Card => other !== undefined && other.status !== "done");
40
40
  }
41
41
 
42
+ /**
43
+ * A list of cards as a reader meets it: `#2 (doing), #3 (blocked)`.
44
+ *
45
+ * The lane is what turns ids into a decision — a finished card is about to start, a
46
+ * `doing` one is to leave alone — so it is never left out. One wording serves the
47
+ * refusal sentence, the page's reason line, and the mirror question of what a card is
48
+ * holding up: every one of them is a list of cards read to decide something, so a second
49
+ * formatter would be a second way for the same list to read.
50
+ */
51
+ export function lanesText(cards: readonly Card[]): string {
52
+ return cards.map((card) => `${cardRef(card.id)} (${card.status})`).join(", ");
53
+ }
54
+
42
55
  /** The cards that wait on `id`, oldest id first, so a report reads in board order. */
43
56
  export function dependentsOf(id: string, board: BoardIndex): Card[] {
44
57
  return [...board.values()].filter((card) => dependsOf(card).includes(id)).sort((a, b) => Number(a.id) - Number(b.id));
45
58
  }
46
59
 
60
+ /**
61
+ * The cards still waiting on `id`: the work this card is holding up.
62
+ *
63
+ * The mirror of {@link blockersOf}, and for the same reason — a finished dependent has
64
+ * stopped waiting, so it stops counting here and a card whose only dependents are done
65
+ * reads as one nothing waits on. `dependentsOf` keeps every edge, because the drop
66
+ * refusal is about a link that would be stranded rather than about work that is held up.
67
+ */
68
+ export function openDependentsOf(id: string, board: BoardIndex): Card[] {
69
+ return dependentsOf(id, board).filter((card) => card.status !== "done");
70
+ }
71
+
47
72
  /** The sentence a claim, a move, or an assignment is refused with while `card` waits, or undefined. */
48
73
  export function waitingOn(card: Card, board: BoardIndex): string | undefined {
49
74
  const blockers = blockersOf(card, board);
50
75
  if (blockers.length === 0) return undefined;
51
- const named = blockers.map((blocker) => `${cardRef(blocker.id)} (${blocker.status})`).join(", ");
52
- return `${cardRef(card.id)} waits on ${named}.`;
76
+ return `${cardRef(card.id)} waits on ${lanesText(blockers)}.`;
53
77
  }
54
78
 
55
79
  /** The sentence a drop is refused with while another card waits on `id`, or undefined. */
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * A page is one loopback server owned by one session's process, so nothing about it is
5
5
  * discoverable without a note left where the repository's state already lives. That note
6
- * is `<board>/page.json`: the port, the seat serving it, and a *view* token — never the
7
- * write token, which stays in the URL the lead's own browser was opened at.
6
+ * is `<board>/page.json`: the port, the seat serving it, and the token the page reads by.
7
+ * The page cannot change a card, so the note carries nothing a reader could misuse.
8
8
  *
9
9
  * Two rules make the file safe to trust:
10
10
  *
@@ -22,18 +22,50 @@
22
22
  import { access, link, readFile, rename, rm, writeFile } from "node:fs/promises";
23
23
  import { join } from "node:path";
24
24
  import { hasErrorCode } from "../errors.ts";
25
+ import { abortableSleep } from "../timer.ts";
25
26
 
26
27
  /** The file, beside the board's `cards/` directory. */
27
28
  export const PAGE_FILE = "page.json";
28
29
 
29
- /** How long a probe waits before it calls the page gone. */
30
- export const PROBE_MS = 600;
30
+ /**
31
+ * The prefix the page answers under on the session's one server.
32
+ *
33
+ * Named here, beside the routes below, because three callers have to agree on it: the
34
+ * page's router (which matches the stripped path), the note a second session reads, and
35
+ * the probe that asks whether the page is alive. A session serves several pages on one
36
+ * origin, so the board is never at the root.
37
+ */
38
+ export const BOARD_BASE = "/board";
39
+
40
+ /**
41
+ * The two routes a probe asks for, named here because the probe and the page's router have
42
+ * to agree on them.
43
+ *
44
+ * `PING_PATH` is the cheap liveness answer this build serves; `SNAPSHOT_PATH` is what a
45
+ * page from an older build answers instead, which is why the probe falls back to it. A
46
+ * literal repeated in the router is a path that can move out from under the probe with
47
+ * every test still green — the probe would simply stop finding a live page.
48
+ *
49
+ * Both are the paths *inside* the page, which is what its router sees: the server strips
50
+ * `BOARD_BASE` before the route runs, and the probe adds it back.
51
+ */
52
+ export const PING_PATH = "/api/ping";
53
+ export const SNAPSHOT_PATH = "/api/board";
54
+
55
+ /**
56
+ * How long a probe waits before it calls the page gone.
57
+ *
58
+ * Long enough for a page that is busy answering a browser: the probe shares the event
59
+ * loop with the snapshot route, and a page declared dead while it is merely slow is taken
60
+ * over — two servers, one note.
61
+ */
62
+ export const PROBE_MS = 1_500;
31
63
 
32
- /** The port, the seat, and the credential a viewer may hold. */
64
+ /** The port, the seat, and the credential the page reads by. */
33
65
  export interface PagePointer {
34
66
  port: number;
35
- /** A token that satisfies every read and no write. */
36
- viewToken: string;
67
+ /** The page's one token: every read, and nothing else to authorise. */
68
+ token: string;
37
69
  /** The seat the page writes as. */
38
70
  seat: { id?: string; label: string };
39
71
  /** When the page started, so a reader can tell a fresh note from a stale one. */
@@ -41,11 +73,11 @@ export interface PagePointer {
41
73
  }
42
74
 
43
75
  /** The fetch a probe uses: the global one in production, a stub in a test. */
44
- export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean }>;
76
+ export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean; status?: number }>;
45
77
 
46
- /** The URL a viewer opens: the page, with the credential that may only read it. */
47
- export function viewUrlOf(pointer: PagePointer): string {
48
- return `http://127.0.0.1:${pointer.port}/?token=${pointer.viewToken}`;
78
+ /** The URL for this page, wherever it is opened from. */
79
+ export function urlOf(pointer: PagePointer): string {
80
+ return `http://127.0.0.1:${pointer.port}${BOARD_BASE}/?token=${pointer.token}`;
49
81
  }
50
82
 
51
83
  /**
@@ -126,9 +158,9 @@ export async function createPointer(dir: string, pointer: PagePointer): Promise<
126
158
  * findable. The move is atomic, so the file this call holds is the file that was there;
127
159
  * a note that is not the one proved dead goes back where it came from.
128
160
  */
129
- export async function clearPointer(dir: string, viewToken: string): Promise<boolean> {
161
+ export async function clearPointer(dir: string, token: string): Promise<boolean> {
130
162
  const pointer = await readPointer(dir);
131
- if (pointer === undefined || pointer.viewToken !== viewToken) return false;
163
+ if (pointer === undefined || pointer.token !== token) return false;
132
164
  const target = join(dir, PAGE_FILE);
133
165
  const held = `${target}.gone-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
134
166
  try {
@@ -138,7 +170,7 @@ export async function clearPointer(dir: string, viewToken: string): Promise<bool
138
170
  return false;
139
171
  }
140
172
  const moved = await readPointerAt(held);
141
- if (moved === undefined || moved.viewToken !== viewToken) {
173
+ if (moved === undefined || moved.token !== token) {
142
174
  await putBack(held, target);
143
175
  return false;
144
176
  }
@@ -164,6 +196,14 @@ async function putBack(held: string, target: string): Promise<void> {
164
196
  /** How many times a caller tries to own the note before it gives up for good. */
165
197
  export const NOTE_ATTEMPTS = 3;
166
198
 
199
+ /** How long a page that has written its note but does not answer yet is given, in steps. */
200
+ export const YOUNG_POLLS = 8;
201
+ const YOUNG_POLL_MS = 75;
202
+
203
+ /** How many times a caller looks again at a name that is momentarily free. */
204
+ const EMPTY_LOOKS = 20;
205
+ const EMPTY_LOOK_MS = 25;
206
+
167
207
  /** What one `/board` found where the note should be. */
168
208
  export type PointerOutcome =
169
209
  | { kind: "written"; took?: PagePointer }
@@ -191,46 +231,82 @@ export type PointerOutcome =
191
231
  */
192
232
  export async function settlePointer(dir: string, note: PagePointer, probe?: ProbeFetch): Promise<PointerOutcome> {
193
233
  let took: PagePointer | undefined;
194
- for (let attempt = 0; attempt < NOTE_ATTEMPTS; attempt++) {
234
+ let attempts = 0;
235
+ let looks = 0;
236
+ while (attempts < NOTE_ATTEMPTS) {
195
237
  // A retry writes a fresh `startedAt`, so a reader can tell it from the first attempt.
196
- const candidate = attempt === 0 ? note : { ...note, startedAt: note.startedAt + attempt };
238
+ const candidate = attempts === 0 ? note : { ...note, startedAt: note.startedAt + attempts };
197
239
  if (await createPointer(dir, candidate))
198
240
  return took === undefined ? { kind: "written" } : { kind: "written", took };
199
241
  const winner = await readPointer(dir);
200
242
  if (winner === undefined) {
201
- // No note at all means the page that had it cleared it in this instant: try again
202
- // rather than reporting a file that is not there. Only a file that stays, and
203
- // cannot be read, is the caller's dead end.
204
- if (!(await noteExists(dir))) continue;
205
- return { kind: "unreadable" };
243
+ // No note at all means a page is between clearing its note and writing the next one.
244
+ // That is a few microseconds of somebody else's work, not a takeover attempt of this
245
+ // caller's, so the name is chased without spending one; a file that stays, and cannot
246
+ // be read, is the caller's dead end.
247
+ if (await noteExists(dir)) return { kind: "unreadable" };
248
+ if (looks >= EMPTY_LOOKS) return { kind: "busy" };
249
+ looks += 1;
250
+ await abortableSleep(EMPTY_LOOK_MS);
251
+ continue;
206
252
  }
207
- if (await probePage(winner, probe)) return { kind: "attached", pointer: winner };
253
+ attempts += 1;
254
+ if (await answerOrWait(winner, probe)) return { kind: "attached", pointer: winner };
208
255
  // Only that exact note: a note a newer page wrote is not this caller's to take. A
209
256
  // refused clear means somebody else got there first, so the next attempt re-reads.
210
- if (await clearPointer(dir, winner.viewToken)) took = winner;
257
+ if (await clearPointer(dir, winner.token)) took = winner;
211
258
  }
212
259
  const last = await readPointer(dir);
213
260
  if (last !== undefined) return { kind: "stale", pointer: last };
214
261
  return (await noteExists(dir)) ? { kind: "unreadable" } : { kind: "busy" };
215
262
  }
216
263
 
264
+ /**
265
+ * Whether the winner answers, giving a page that is still coming up its full probe window.
266
+ *
267
+ * A note is written before its server can accept a connection, so the loser of a simultaneous
268
+ * start reads a note whose page is a few milliseconds behind it. One refused connection is
269
+ * therefore not proof of death: stealing that note serves a second page, and calling it stale
270
+ * reports a page that exists as one that did not start. The poll is bounded at the same
271
+ * 600 ms a probe itself may spend, so a note whose page is really gone is still taken over.
272
+ */
273
+ async function answerOrWait(pointer: PagePointer, probe?: ProbeFetch): Promise<boolean> {
274
+ for (let attempt = 0; attempt < YOUNG_POLLS; attempt++) {
275
+ if (await probePage(pointer, probe)) return true;
276
+ await abortableSleep(YOUNG_POLL_MS);
277
+ }
278
+ return false;
279
+ }
280
+
217
281
  /**
218
282
  * Whether the page a pointer names is answering.
219
283
  *
220
- * A page that answers with anything but a 200 to the snapshot request is not one to
221
- * attach to: a port reused by another program, a server shutting down, and a page that
222
- * refuses the token all read the same, and all mean "serve your own".
284
+ * The question goes to the cheap liveness route, not to the snapshot: a probe runs while
285
+ * another session may be serving a browser, and asking for the snapshot would make a busy
286
+ * page look like a dead one — the very race that costs a takeover and leaves two servers.
287
+ * A page from a build without that route answers 404 there and is asked for its snapshot
288
+ * once, so an upgrade mid-session attaches instead of stealing the seat.
289
+ *
290
+ * A page that answers with anything but a 200 is not one to attach to: a port reused by
291
+ * another program, a server shutting down, and a page that refuses the token all read the
292
+ * same, and all mean "serve your own".
223
293
  */
224
294
  export async function probePage(
225
295
  pointer: PagePointer,
226
296
  fetchLike: ProbeFetch = (url, init) => fetch(url, init),
227
297
  timeoutMs: number = PROBE_MS,
228
298
  ): Promise<boolean> {
229
- try {
230
- const response = await fetchLike(`http://127.0.0.1:${pointer.port}/api/board?token=${pointer.viewToken}`, {
299
+ const ask = (path: string): Promise<{ ok: boolean; status?: number }> =>
300
+ fetchLike(`http://127.0.0.1:${pointer.port}${BOARD_BASE}${path}?token=${pointer.token}`, {
231
301
  signal: AbortSignal.timeout(timeoutMs),
232
302
  });
233
- return response.ok;
303
+ try {
304
+ const ping = await ask(PING_PATH);
305
+ if (ping.ok) return true;
306
+ // A page this build started always answers the ping; a 404 is one started by an
307
+ // older build, whose only liveness answer is its snapshot.
308
+ if (ping.status !== 404) return false;
309
+ return (await ask(SNAPSHOT_PATH)).ok;
234
310
  } catch {
235
311
  return false;
236
312
  }
@@ -239,9 +315,12 @@ export async function probePage(
239
315
  /** A parsed file as a pointer, or undefined when it is not one this build can trust. */
240
316
  function asPointer(value: unknown): PagePointer | undefined {
241
317
  if (typeof value !== "object" || value === null) return undefined;
242
- const { port, viewToken, seat, startedAt } = value as Record<string, unknown>;
318
+ const { port, seat, startedAt } = value as Record<string, unknown>;
319
+ // A note written by the build with two tokens carries `viewToken`; it is this page's
320
+ // only credential now, so an upgrade reads it instead of refusing to serve its own note.
321
+ const { token = (value as { viewToken?: unknown }).viewToken } = value as { token?: unknown };
243
322
  if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65_535) return undefined;
244
- if (typeof viewToken !== "string" || viewToken === "") return undefined;
323
+ if (typeof token !== "string" || token === "") return undefined;
245
324
  if (typeof startedAt !== "number") return undefined;
246
325
  if (typeof seat !== "object" || seat === null) return undefined;
247
326
  const { id, label } = seat as Record<string, unknown>;
@@ -249,7 +328,7 @@ function asPointer(value: unknown): PagePointer | undefined {
249
328
  if (id !== undefined && typeof id !== "string") return undefined;
250
329
  return {
251
330
  port,
252
- viewToken,
331
+ token,
253
332
  seat: id === undefined ? { label } : { id, label },
254
333
  startedAt,
255
334
  };
@@ -47,11 +47,20 @@ export function formatRound(cards: readonly Card[], round: RoundSummary): string
47
47
  return lines.join("\n");
48
48
  }
49
49
 
50
- /** `1 owner`, `2 owners`. */
50
+ /**
51
+ * `1 owner`, `2 owners`.
52
+ *
53
+ * Written as concatenation rather than a template, because the page's bundle carries
54
+ * this function's *source*: a backtick or an interpolation in it would end the string it
55
+ * is spliced into. `round-text.test.ts` pins both, and `PLURAL_JS` is how it crosses.
56
+ */
51
57
  export function plural(count: number, noun: string): string {
52
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
58
+ return count + " " + noun + (count === 1 ? "" : "s");
53
59
  }
54
60
 
61
+ /** The same body, as the browser receives it: one wording for `1 card`, not two. */
62
+ export const PLURAL_JS = String(plural);
63
+
55
64
  /** `#4 doing planned`, the brief a rollup group needs. */
56
65
  function brief(card: Card): string {
57
66
  const planned = card.status === "doing" ? (card.plan === undefined ? " unplanned" : " planned") : "";