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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
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,25 @@
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
+ * How long a probe waits before it calls the page gone.
32
+ *
33
+ * Long enough for a page that is busy answering a browser: the probe shares the event
34
+ * loop with the snapshot route, and a page declared dead while it is merely slow is taken
35
+ * over — two servers, one note.
36
+ */
37
+ export const PROBE_MS = 1_500;
31
38
 
32
- /** The port, the seat, and the credential a viewer may hold. */
39
+ /** The port, the seat, and the credential the page reads by. */
33
40
  export interface PagePointer {
34
41
  port: number;
35
- /** A token that satisfies every read and no write. */
36
- viewToken: string;
42
+ /** The page's one token: every read, and nothing else to authorise. */
43
+ token: string;
37
44
  /** The seat the page writes as. */
38
45
  seat: { id?: string; label: string };
39
46
  /** When the page started, so a reader can tell a fresh note from a stale one. */
@@ -41,11 +48,11 @@ export interface PagePointer {
41
48
  }
42
49
 
43
50
  /** 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 }>;
51
+ export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean; status?: number }>;
45
52
 
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}`;
53
+ /** The URL for this page, wherever it is opened from. */
54
+ export function urlOf(pointer: PagePointer): string {
55
+ return `http://127.0.0.1:${pointer.port}/?token=${pointer.token}`;
49
56
  }
50
57
 
51
58
  /**
@@ -126,9 +133,9 @@ export async function createPointer(dir: string, pointer: PagePointer): Promise<
126
133
  * findable. The move is atomic, so the file this call holds is the file that was there;
127
134
  * a note that is not the one proved dead goes back where it came from.
128
135
  */
129
- export async function clearPointer(dir: string, viewToken: string): Promise<boolean> {
136
+ export async function clearPointer(dir: string, token: string): Promise<boolean> {
130
137
  const pointer = await readPointer(dir);
131
- if (pointer === undefined || pointer.viewToken !== viewToken) return false;
138
+ if (pointer === undefined || pointer.token !== token) return false;
132
139
  const target = join(dir, PAGE_FILE);
133
140
  const held = `${target}.gone-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
134
141
  try {
@@ -138,7 +145,7 @@ export async function clearPointer(dir: string, viewToken: string): Promise<bool
138
145
  return false;
139
146
  }
140
147
  const moved = await readPointerAt(held);
141
- if (moved === undefined || moved.viewToken !== viewToken) {
148
+ if (moved === undefined || moved.token !== token) {
142
149
  await putBack(held, target);
143
150
  return false;
144
151
  }
@@ -164,6 +171,14 @@ async function putBack(held: string, target: string): Promise<void> {
164
171
  /** How many times a caller tries to own the note before it gives up for good. */
165
172
  export const NOTE_ATTEMPTS = 3;
166
173
 
174
+ /** How long a page that has written its note but does not answer yet is given, in steps. */
175
+ export const YOUNG_POLLS = 8;
176
+ const YOUNG_POLL_MS = 75;
177
+
178
+ /** How many times a caller looks again at a name that is momentarily free. */
179
+ const EMPTY_LOOKS = 20;
180
+ const EMPTY_LOOK_MS = 25;
181
+
167
182
  /** What one `/board` found where the note should be. */
168
183
  export type PointerOutcome =
169
184
  | { kind: "written"; took?: PagePointer }
@@ -191,46 +206,82 @@ export type PointerOutcome =
191
206
  */
192
207
  export async function settlePointer(dir: string, note: PagePointer, probe?: ProbeFetch): Promise<PointerOutcome> {
193
208
  let took: PagePointer | undefined;
194
- for (let attempt = 0; attempt < NOTE_ATTEMPTS; attempt++) {
209
+ let attempts = 0;
210
+ let looks = 0;
211
+ while (attempts < NOTE_ATTEMPTS) {
195
212
  // 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 };
213
+ const candidate = attempts === 0 ? note : { ...note, startedAt: note.startedAt + attempts };
197
214
  if (await createPointer(dir, candidate))
198
215
  return took === undefined ? { kind: "written" } : { kind: "written", took };
199
216
  const winner = await readPointer(dir);
200
217
  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" };
218
+ // No note at all means a page is between clearing its note and writing the next one.
219
+ // That is a few microseconds of somebody else's work, not a takeover attempt of this
220
+ // caller's, so the name is chased without spending one; a file that stays, and cannot
221
+ // be read, is the caller's dead end.
222
+ if (await noteExists(dir)) return { kind: "unreadable" };
223
+ if (looks >= EMPTY_LOOKS) return { kind: "busy" };
224
+ looks += 1;
225
+ await abortableSleep(EMPTY_LOOK_MS);
226
+ continue;
206
227
  }
207
- if (await probePage(winner, probe)) return { kind: "attached", pointer: winner };
228
+ attempts += 1;
229
+ if (await answerOrWait(winner, probe)) return { kind: "attached", pointer: winner };
208
230
  // Only that exact note: a note a newer page wrote is not this caller's to take. A
209
231
  // refused clear means somebody else got there first, so the next attempt re-reads.
210
- if (await clearPointer(dir, winner.viewToken)) took = winner;
232
+ if (await clearPointer(dir, winner.token)) took = winner;
211
233
  }
212
234
  const last = await readPointer(dir);
213
235
  if (last !== undefined) return { kind: "stale", pointer: last };
214
236
  return (await noteExists(dir)) ? { kind: "unreadable" } : { kind: "busy" };
215
237
  }
216
238
 
239
+ /**
240
+ * Whether the winner answers, giving a page that is still coming up its full probe window.
241
+ *
242
+ * A note is written before its server can accept a connection, so the loser of a simultaneous
243
+ * start reads a note whose page is a few milliseconds behind it. One refused connection is
244
+ * therefore not proof of death: stealing that note serves a second page, and calling it stale
245
+ * reports a page that exists as one that did not start. The poll is bounded at the same
246
+ * 600 ms a probe itself may spend, so a note whose page is really gone is still taken over.
247
+ */
248
+ async function answerOrWait(pointer: PagePointer, probe?: ProbeFetch): Promise<boolean> {
249
+ for (let attempt = 0; attempt < YOUNG_POLLS; attempt++) {
250
+ if (await probePage(pointer, probe)) return true;
251
+ await abortableSleep(YOUNG_POLL_MS);
252
+ }
253
+ return false;
254
+ }
255
+
217
256
  /**
218
257
  * Whether the page a pointer names is answering.
219
258
  *
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".
259
+ * The question goes to the cheap liveness route, not to the snapshot: a probe runs while
260
+ * another session may be serving a browser, and asking for the snapshot would make a busy
261
+ * page look like a dead one — the very race that costs a takeover and leaves two servers.
262
+ * A page from a build without that route answers 404 there and is asked for its snapshot
263
+ * once, so an upgrade mid-session attaches instead of stealing the seat.
264
+ *
265
+ * A page that answers with anything but a 200 is not one to attach to: a port reused by
266
+ * another program, a server shutting down, and a page that refuses the token all read the
267
+ * same, and all mean "serve your own".
223
268
  */
224
269
  export async function probePage(
225
270
  pointer: PagePointer,
226
271
  fetchLike: ProbeFetch = (url, init) => fetch(url, init),
227
272
  timeoutMs: number = PROBE_MS,
228
273
  ): Promise<boolean> {
229
- try {
230
- const response = await fetchLike(`http://127.0.0.1:${pointer.port}/api/board?token=${pointer.viewToken}`, {
274
+ const ask = (path: string): Promise<{ ok: boolean; status?: number }> =>
275
+ fetchLike(`http://127.0.0.1:${pointer.port}${path}?token=${pointer.token}`, {
231
276
  signal: AbortSignal.timeout(timeoutMs),
232
277
  });
233
- return response.ok;
278
+ try {
279
+ const ping = await ask("/api/ping");
280
+ if (ping.ok) return true;
281
+ // A page this build started always answers the ping; a 404 is one started by an
282
+ // older build, whose only liveness answer is its snapshot.
283
+ if (ping.status !== 404) return false;
284
+ return (await ask("/api/board")).ok;
234
285
  } catch {
235
286
  return false;
236
287
  }
@@ -239,9 +290,12 @@ export async function probePage(
239
290
  /** A parsed file as a pointer, or undefined when it is not one this build can trust. */
240
291
  function asPointer(value: unknown): PagePointer | undefined {
241
292
  if (typeof value !== "object" || value === null) return undefined;
242
- const { port, viewToken, seat, startedAt } = value as Record<string, unknown>;
293
+ const { port, seat, startedAt } = value as Record<string, unknown>;
294
+ // A note written by the build with two tokens carries `viewToken`; it is this page's
295
+ // only credential now, so an upgrade reads it instead of refusing to serve its own note.
296
+ const { token = (value as { viewToken?: unknown }).viewToken } = value as { token?: unknown };
243
297
  if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65_535) return undefined;
244
- if (typeof viewToken !== "string" || viewToken === "") return undefined;
298
+ if (typeof token !== "string" || token === "") return undefined;
245
299
  if (typeof startedAt !== "number") return undefined;
246
300
  if (typeof seat !== "object" || seat === null) return undefined;
247
301
  const { id, label } = seat as Record<string, unknown>;
@@ -249,7 +303,7 @@ function asPointer(value: unknown): PagePointer | undefined {
249
303
  if (id !== undefined && typeof id !== "string") return undefined;
250
304
  return {
251
305
  port,
252
- viewToken,
306
+ token,
253
307
  seat: id === undefined ? { label } : { id, label },
254
308
  startedAt,
255
309
  };
@@ -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") : "";
@@ -8,8 +8,8 @@
8
8
  * know — nobody has touched it in half an hour — not that its owner's process is
9
9
  * gone.
10
10
  *
11
- * The groups overlap on purpose. A `doing` card with no plan and no touch for an
12
- * hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
11
+ * The groups overlap on purpose. A `doing` card with no plan and no touch for half
12
+ * an hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
13
13
  * half the reason a lead should look at it.
14
14
  *
15
15
  * Pure: no I/O and no clock of its own.
@@ -20,28 +20,33 @@ import type { Card } from "./card.ts";
20
20
  /** How long a `doing` card may go untouched before the rollup calls it idle. */
21
21
  export const IDLE_MS = 30 * 60 * 1_000;
22
22
 
23
- /** One owner, the live cards it holds, and the ones it finished. */
24
- export interface RoundGroup {
23
+ /**
24
+ * One owner, the live cards it holds, and the ones it finished.
25
+ *
26
+ * `T` is what the caller handed in: the rollup never builds a card, so a snapshot whose
27
+ * cards carry derived facts hands them back on the same terms it received them.
28
+ */
29
+ export interface RoundGroup<T extends Card = Card> {
25
30
  label: string;
26
- cards: Card[];
31
+ cards: T[];
27
32
  /** This owner's finished cards: history, kept so a teammate who is done is not erased. */
28
- done: Card[];
33
+ done: T[];
29
34
  }
30
35
 
31
36
  /** The lead's rollup of one board. */
32
- export interface RoundSummary {
37
+ export interface RoundSummary<T extends Card = Card> {
33
38
  /** Every owner with at least one unfinished card, ordered by label. */
34
- owners: RoundGroup[];
39
+ owners: RoundGroup<T>[];
35
40
  /** `todo` cards: nobody has taken them yet. */
36
- unassigned: Card[];
41
+ unassigned: T[];
37
42
  /** `doing` cards with no plan recorded: work started before it was planned. */
38
- unplanned: Card[];
43
+ unplanned: T[];
39
44
  /** `blocked` cards, and the note that says why. */
40
- blocked: Card[];
45
+ blocked: T[];
41
46
  /** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
42
- idle: Card[];
47
+ idle: T[];
43
48
  /** Finished cards, oldest first, kept as history. */
44
- done: Card[];
49
+ done: T[];
45
50
  }
46
51
 
47
52
  /**
@@ -55,13 +60,13 @@ export interface RoundSummary {
55
60
  * whose labels differ only by case are one owner — a name is case-insensitive when
56
61
  * it is a send target, so it is here too — and the first spelling seen is kept.
57
62
  */
58
- export function buildRound(cards: readonly Card[], now: number): RoundSummary {
59
- const groups = new Map<string, RoundGroup>();
60
- const unassigned: Card[] = [];
61
- const unplanned: Card[] = [];
62
- const blocked: Card[] = [];
63
- const idle: Card[] = [];
64
- const done: Card[] = [];
63
+ export function buildRound<T extends Card = Card>(cards: readonly T[], now: number): RoundSummary<T> {
64
+ const groups = new Map<string, RoundGroup<T>>();
65
+ const unassigned: T[] = [];
66
+ const unplanned: T[] = [];
67
+ const blocked: T[] = [];
68
+ const idle: T[] = [];
69
+ const done: T[] = [];
65
70
 
66
71
  for (const card of cards) {
67
72
  if (card.status === "todo") unassigned.push(card);