@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
@@ -35,12 +35,24 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
35
35
  handler: async (args: string, ctx: ExtensionCommandContext) => {
36
36
  const word = args.trim().toLowerCase();
37
37
  if (word === "status") {
38
- ctx.ui.notify(statusLine(page.status(), joined), "info");
38
+ ctx.ui.notify(statusLine(await page.status(), joined), "info");
39
39
  return;
40
40
  }
41
41
  if (word === "stop") {
42
- const status = page.status();
43
- // A viewer's page is somebody else's to stop, and saying whose is the answer.
42
+ const status = await page.status();
43
+ // A note nobody answers is not a page to leave for the next session to trip over:
44
+ // it is dropped here, and the line says whose it was.
45
+ if (status.stale === true) {
46
+ const dropped = await page.dropStale(ctx);
47
+ ctx.ui.notify(
48
+ dropped
49
+ ? `board: dropped ${status.seat}'s note for :${status.port} — it answered nobody`
50
+ : `board: nothing to stop here — ${status.seat} is answering on :${status.port}`,
51
+ "info",
52
+ );
53
+ return;
54
+ }
55
+ // A page another session serves is that session's to stop, and saying whose it is is the answer.
44
56
  if (status.attached === true) {
45
57
  ctx.ui.notify(
46
58
  `board: nothing to stop here — ${status.seat} serves this repository on :${status.port}`,
@@ -64,10 +76,13 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
64
76
  // this session's own label when it watches a page somebody else serves.
65
77
  if (started.kind === "failed") return;
66
78
  joined = await team.join(ctx, joinLabel(started.kind === "served"));
79
+ // The address is on the line because the browser is not the only way in: a machine
80
+ // with no default browser prints it here, and a tab closed by mistake is one
81
+ // command from being opened again.
67
82
  ctx.ui.notify(
68
83
  started.kind === "attached"
69
- ? `board: ${started.seat} is serving this repository on :${started.port} — viewing${teamLine(joined)}`
70
- : `board: ${started.seat} serving ${started.where} on :${started.port}${teamLine(joined)}`,
84
+ ? `board: ${started.seat} is serving this repository on :${started.port} — viewing · open ${started.url}${teamLine(joined)}`
85
+ : `board: ${started.seat} serving ${started.where} on :${started.port} · open ${started.url}${teamLine(joined)}`,
71
86
  "info",
72
87
  );
73
88
  },
@@ -80,6 +95,9 @@ export function statusLine(status: BoardPageStatus, team?: JoinResult): string {
80
95
  const watching = plural(status.watchers ?? 0, "tab");
81
96
  return `board: ${status.seat} serving on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
82
97
  }
98
+ if (status.stale === true) {
99
+ return `board: watching ${status.seat} on :${status.port} — that note answers nobody; /board stop drops it${teamLine(team)}`;
100
+ }
83
101
  if (status.attached === true) {
84
102
  return `board: watching ${status.seat} on :${status.port} — nothing to stop here${teamLine(team)}`;
85
103
  }
@@ -12,18 +12,85 @@
12
12
  */
13
13
 
14
14
  import { type Card, cardRef, DEFAULT_PRIORITY, dependsOf, priorityOf } from "../../lib/board/card.ts";
15
- import type { BoardIndex } from "../../lib/board/links.ts";
15
+ import { type BoardIndex, lanesText, openDependentsOf } from "../../lib/board/links.ts";
16
+ import type { Skip } from "../../lib/board/store.ts";
17
+ import { formatAge } from "../../lib/format.ts";
16
18
  import { plural } from "../../lib/board/round-text.ts";
17
19
 
18
20
  /** One card: the headline, then the state a reader needs in order to act on it. */
19
- export function formatCard(card: Card, board?: BoardIndex): string {
20
- return [`${cardRef(card.id)} ${card.status} · ${card.title}`, ...detailLines(card, board)].join("\n");
21
+ export function formatCard(card: Card, board?: BoardIndex, now?: number): string {
22
+ return [`${cardRef(card.id)} ${card.status} · ${card.title}`, ...detailLines(card, board, now)].join("\n");
21
23
  }
22
24
 
23
- /** A board, in lane order. */
24
- export function formatCards(cards: readonly Card[]): string {
25
- if (cards.length === 0) return "The board is empty.";
26
- return cards.map((card) => listLine(card)).join("\n");
25
+ /**
26
+ * What a listing cannot show, and therefore has to say.
27
+ *
28
+ * A footnote is the difference between a listing and the board: an omission nobody
29
+ * accounted for reads as a smaller board — or as history that was deleted.
30
+ */
31
+ export interface ListingNotes {
32
+ /** Finished cards the board holds that a lane-less listing left out. */
33
+ hiddenDone: number;
34
+ /** Cards set aside in the archive, which no board read opens. */
35
+ archived: number;
36
+ /** Card files this build could not read. */
37
+ skipped: readonly Skip[];
38
+ }
39
+
40
+ /**
41
+ * A board's listing: the cards, then the footnotes a reader cannot see.
42
+ *
43
+ * One body, because the footnotes belong to the listing: a card file this build cannot
44
+ * read, the finished cards a plain listing left out, and the cards an `archive` set aside.
45
+ */
46
+ export function formatBoard(shown: readonly Card[], notes: ListingNotes): string {
47
+ const lines =
48
+ shown.length === 0 ? [notes.hiddenDone === 0 ? "The board is empty." : "No open cards."] : shown.map(listLine);
49
+ // A finished card is history rather than work, so a listing that names no lane leaves
50
+ // them out; the count is what keeps the omission from reading as a smaller board.
51
+ if (notes.hiddenDone > 0) {
52
+ const them = notes.hiddenDone === 1 ? "it" : "them";
53
+ lines.push(`${plural(notes.hiddenDone, "done card")} hidden; pass status: "done" to read ${them}.`);
54
+ }
55
+ for (const note of [archiveNote(notes.archived), formatSkipped(notes.skipped)]) {
56
+ if (note !== "") lines.push(note);
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+
61
+ /**
62
+ * The archive's listing: what is set aside, and the files there this build refused.
63
+ *
64
+ * The archive is read on request only, so this has no lane footnote of its own — every
65
+ * card in the pile is finished, and a `done` lane would be a second way to say it.
66
+ */
67
+ export function formatArchive(shown: readonly Card[], skipped: readonly Skip[]): string {
68
+ const lines = shown.length === 0 ? ["The archive is empty."] : shown.map(listLine);
69
+ const unreadable = formatSkipped(skipped);
70
+ if (unreadable !== "") lines.push(unreadable);
71
+ return lines.join("\n");
72
+ }
73
+
74
+ /**
75
+ * The one sentence a board listing adds so its archive is not mistaken for deleted history.
76
+ *
77
+ * Empty when nothing is set aside, so the ordinary board says nothing new.
78
+ */
79
+ export function archiveNote(count: number): string {
80
+ if (count === 0) return "";
81
+ return `${plural(count, "card")} ${count === 1 ? "is" : "are"} set aside in the archive; pass archived: true to read ${count === 1 ? "it" : "them"}.`;
82
+ }
83
+
84
+ /**
85
+ * The files the board could not read, or nothing when it read everything.
86
+ *
87
+ * A skipped file is not an absent one: a board written by a newer build would
88
+ * otherwise read as "nobody has planned anything" while the work sits on disk.
89
+ */
90
+ export function formatSkipped(skipped: readonly Skip[]): string {
91
+ if (skipped.length === 0) return "";
92
+ const files = skipped.map((entry) => `${entry.file} (${entry.why})`).join(", ");
93
+ return `${plural(skipped.length, "file")} this build cannot read: ${files}`;
27
94
  }
28
95
 
29
96
  /** The dropped id, which is the only thing left of the card. */
@@ -31,22 +98,53 @@ export function formatDropped(id: string): string {
31
98
  return `${cardRef(id)} dropped, with its notes.`;
32
99
  }
33
100
 
101
+ /**
102
+ * The ids an archive set aside, and how to read them again.
103
+ *
104
+ * A move has to name the way back or it reads like a deletion: the card is out of the
105
+ * board and its notes, plan, and landed commit are still in its file.
106
+ */
107
+ export function formatArchived(ids: readonly string[]): string {
108
+ const theirs = ids.length === 1 ? "its" : "their";
109
+ return `${plural(ids.length, "card")} archived: ${ids.map(cardRef).join(", ")} — out of the board, ${theirs} notes and plan still readable with archived: true.`;
110
+ }
111
+
34
112
  /** A one-line listing: the id, the lane, the title, and who holds it. */
35
113
  function listLine(card: Card): string {
36
114
  const holder = card.owner === undefined ? "unassigned" : card.owner.label;
37
115
  const urgency = priorityOf(card) === DEFAULT_PRIORITY ? "" : ` · ${priorityOf(card)}`;
38
116
  const waits = dependsOf(card).length === 0 ? "" : ` · waits on ${dependsOf(card).map(cardRef).join(", ")}`;
39
- return `${cardRef(card.id)} ${card.status} · ${card.title} · ${holder}${urgency}${waits}`;
117
+ // The two lanes that carry a fact of their own say it here: what a block waits on, and
118
+ // what closed a finished card. Both are one short phrase, so a listing stays one line.
119
+ const blocked = card.blockedBy === undefined ? "" : ` · blocked: ${card.blockedBy}`;
120
+ const landed = card.landed === undefined ? "" : ` · landed ${card.landed.commit}`;
121
+ return `${cardRef(card.id)} ${card.status} · ${card.title} · ${holder}${urgency}${waits}${blocked}${landed}`;
40
122
  }
41
123
 
42
124
  /** The state lines under a card's headline. */
43
- function detailLines(card: Card, board?: BoardIndex): string[] {
125
+ function detailLines(card: Card, board?: BoardIndex, now?: number): string[] {
44
126
  const lines: string[] = [];
45
127
  if (card.owner !== undefined) lines.push(` owner: ${card.owner.label}`);
128
+ // An offer's age is the fact a reader acts on, which is the one place an age belongs in
129
+ // this text: the value is whether anybody has had time to answer.
130
+ if (card.offeredAt !== undefined && now !== undefined) {
131
+ lines.push(` offered: ${formatAge(now, card.offeredAt)} ago, and nobody took it`);
132
+ }
133
+ if (card.blockedBy !== undefined) lines.push(` blocked: ${card.blockedBy}`);
134
+ if (card.landed !== undefined) lines.push(` landed: ${card.landed.commit}`);
46
135
  if (priorityOf(card) !== DEFAULT_PRIORITY) lines.push(` priority: ${priorityOf(card)}`);
47
136
  if (card.plan !== undefined) lines.push(` plan: ${card.plan}`);
48
137
  if (card.paths.length > 0) lines.push(` paths: ${card.paths.join(", ")}`);
49
138
  if (dependsOf(card).length > 0) lines.push(` waits on: ${waitingOf(card, board)}`);
139
+ // The mirror question, for the same board: what leaving this card undone holds up. Only
140
+ // a card with the board in hand can answer it, which is why a bare card read omits it.
141
+ if (board !== undefined) {
142
+ const dependents = openDependentsOf(card.id, board);
143
+ if (dependents.length > 0) {
144
+ const which = dependents.length === 1 ? "card" : "cards";
145
+ lines.push(` waited on by ${dependents.length} ${which}: ${lanesText(dependents)}`);
146
+ }
147
+ }
50
148
  if (card.notes.length > 0) {
51
149
  const last = card.notes.at(-1);
52
150
  if (last) lines.push(` last note (${last.by}): ${last.text}`);
@@ -22,7 +22,7 @@ export type { TeamJoin } from "../../lib/session-tool.ts";
22
22
 
23
23
  /**
24
24
  * The label `/board` lands on: the page's seat when this session serves it, and
25
- * nothing when it watches somebody else's page, so a viewer keeps its own name.
25
+ * nothing when it opened somebody else's page, so a session keeps its own name.
26
26
  */
27
27
  export function joinLabel(served: boolean): string | undefined {
28
28
  return served ? DEFAULT_SEAT : undefined;
@@ -4,9 +4,9 @@
4
4
  * `session_shutdown`.
5
5
  *
6
6
  * Only the lifecycle lives here — the state machine, the port it remembers, the chip
7
- * that names it, and the three closes. What the page *reads* is `snapshot.ts`, what it
8
- * *writes* is `writes.ts`, and how a request reaches either is `transport/`; the router
9
- * knows nothing of the page's markup, and this is still the one place they meet.
7
+ * that names it, and the three closes. What the page *reads* is `snapshot.ts`, and it
8
+ * writes nothing: how a request reaches the snapshot is `transport/`, the router knows
9
+ * nothing of the page's markup, and this is still the one place they meet.
10
10
  *
11
11
  * All state lives in this closure, not at module scope: Pi loads each extension with an
12
12
  * isolated module cache, so a module variable would be one copy per extension.
@@ -27,14 +27,14 @@ import {
27
27
  probePage,
28
28
  readPointer,
29
29
  settlePointer,
30
- viewUrlOf,
30
+ urlOf,
31
31
  } from "../../lib/board/page-pointer.ts";
32
32
  import { mintToken } from "../../lib/http/index.ts";
33
+ import type { IdleReason } from "../../lib/http/sink.ts";
33
34
  import { join } from "node:path";
34
35
  import { type BoardConfig, loadBoardConfig } from "./config.ts";
35
36
  import { pageActor } from "./seat.ts";
36
37
  import { viewOf } from "./snapshot.ts";
37
- import { writeOf } from "./writes.ts";
38
38
  import { type BoardServer, createBoardServer } from "./transport/server.ts";
39
39
  import { BOARD_CLIENT_JS } from "./view/client.ts";
40
40
  import { type PageView, renderBoardPage } from "./view/page.ts";
@@ -57,11 +57,26 @@ export function randomPort(): number {
57
57
  */
58
58
  export const IDLE_STOP_MS = 60_000;
59
59
 
60
+ /**
61
+ * How long a page may stand with no tab ever attached.
62
+ *
63
+ * `/board` opens a browser, and a browser that does not open (a swallowed failure, a
64
+ * terminal on a machine with no default browser) would otherwise leave the port bound for
65
+ * the rest of the session. Two minutes is long enough for a person to finish reading the
66
+ * transcript and click the link `/board` printed.
67
+ */
68
+ export const IDLE_FIRST_MS = 120_000;
69
+
60
70
  /** What `/board status` prints. */
61
71
  export interface BoardPageStatus {
62
72
  running: boolean;
63
73
  /** True when this session opened somebody else's page instead of serving one. */
64
74
  attached?: boolean;
75
+ /**
76
+ * True when that page no longer answers: the note is there, the process behind it is
77
+ * not, and `/board stop` may drop it.
78
+ */
79
+ stale?: boolean;
65
80
  /** The name the page serves as: a seat, not the session that opened it. */
66
81
  seat?: string;
67
82
  port?: number;
@@ -90,6 +105,8 @@ export interface BoardPageDeps {
90
105
  interval?: RepeatTimer;
91
106
  /** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
92
107
  idleMs?: number;
108
+ /** Grace for a page no tab ever attached to; defaults to {@link IDLE_FIRST_MS}. */
109
+ firstIdleMs?: number;
93
110
  /** Whether a page another session left a note about is answering; a test drives it. */
94
111
  probe?: ProbeFetch;
95
112
  /** How the `board` section answers; a test drives it without a config file. */
@@ -100,12 +117,15 @@ export interface BoardPageDeps {
100
117
  export interface BoardPage {
101
118
  start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
102
119
  stop: (ctx: ExtensionContext) => Promise<boolean>;
103
- status: () => BoardPageStatus;
120
+ /** What `/board status` prints: served here, watched, watched and dead, or absent. */
121
+ status: () => Promise<BoardPageStatus>;
122
+ /** Drop the note of a page that has stopped answering. False when it still answers. */
123
+ dropStale: (ctx: ExtensionContext) => Promise<boolean>;
104
124
  closeAll: () => Promise<void>;
105
125
  }
106
126
 
107
127
  export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {}): BoardPage {
108
- let running: { server: BoardServer; store: BoardStore; viewToken: string } | undefined;
128
+ let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
109
129
  /** The page another session serves, when this session opened that one instead. */
110
130
  let attachedTo: PagePointer | undefined;
111
131
  let sessionPort: number | undefined;
@@ -115,32 +135,60 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
115
135
 
116
136
  const storeOf = async (ctx: ExtensionContext): Promise<BoardStore> => runtime.store(ctx.cwd);
117
137
 
118
- const stop = async (ctx: ExtensionContext): Promise<boolean> => {
119
- if (!running) return false;
120
- const { server, store, viewToken } = running;
138
+ /**
139
+ * Close the page this session serves, and take its note with it.
140
+ *
141
+ * One close for both callers: `/board stop` has a context and clears the chip, and a
142
+ * clean `session_shutdown` has none. The note must leave either way — a note nobody
143
+ * answers makes every open tab reconnect forever and costs the next `/board` a probe
144
+ * and a takeover before it serves.
145
+ */
146
+ const closeRunning = async (ctx?: ExtensionContext): Promise<boolean> => {
147
+ if (running === undefined) return false;
148
+ const { server, store, token } = running;
121
149
  running = undefined;
122
150
  await server.close();
123
- // The note is the page's, so it leaves with it: a viewer that reads a note nobody
124
- // answers wastes a probe, and the next `/board` would attach to a dead port.
125
- await clearPointer(store.dir, viewToken);
126
- clearChip(ctx);
151
+ await clearPointer(store.dir, token);
152
+ if (ctx !== undefined) clearChip(ctx);
127
153
  return true;
128
154
  };
129
155
 
156
+ const stop = (ctx: ExtensionContext): Promise<boolean> => closeRunning(ctx);
157
+
130
158
  const start = async (ctx: ExtensionContext, view: PageView = "board"): Promise<PageStart> => {
131
- if (running) return describe(running);
132
- const store = await storeOf(ctx);
133
- await store.ensure();
159
+ // `/board` is how the page is summoned, and a page whose tab was closed is one
160
+ // command away from being shown again — the same answer a viewer's `/board` gives.
161
+ if (running !== undefined) {
162
+ (deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
163
+ return describe(running);
164
+ }
165
+ const config = (deps.config ?? loadBoardConfig)(ctx.cwd);
166
+ const pinned = config.port > 0 ? config.port : undefined;
167
+ /** One refusal for every way a start can fail, named by what failed. */
168
+ const failed = (error: unknown): PageStart => {
169
+ const reason = failureReason(error, pinned);
170
+ ctx.ui.notify(`board: the page did not start (${reason})`, "error");
171
+ return { kind: "failed", reason };
172
+ };
173
+ // The board's own directory, then the page: both can fail on a path that exists and
174
+ // cannot be written, and a start that fails must leave no listener behind.
175
+ let store: BoardStore;
176
+ try {
177
+ store = await storeOf(ctx);
178
+ await store.ensure();
179
+ } catch (error) {
180
+ return failed(error);
181
+ }
134
182
  const open = (url: string) => (deps.open ?? openInBrowser)(view === "board" ? url : `${url}&view=team`);
135
183
  /** Watch the page another session serves: its note is the URL, its seat the name. */
136
184
  const attach = (pointer: PagePointer): PageStart => {
137
185
  attachedTo = pointer;
138
- open(viewUrlOf(pointer));
186
+ open(urlOf(pointer));
139
187
  return {
140
188
  kind: "attached",
141
189
  seat: pointer.seat.label,
142
190
  port: pointer.port,
143
- url: viewUrlOf(pointer),
191
+ url: urlOf(pointer),
144
192
  where: store.where(),
145
193
  };
146
194
  };
@@ -151,24 +199,24 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
151
199
  attachedTo = undefined;
152
200
  const actor = () => seat;
153
201
  const now = () => runtime.now();
154
- // Read on every start, so an edit to the section applies to the next `/board`.
155
- const config = (deps.config ?? loadBoardConfig)(ctx.cwd);
156
- const pinned = config.port > 0 ? config.port : undefined;
202
+ // Read above, so a pinned port names itself in a refusal; the port itself is
203
+ // remembered for the session so a closed and reopened page lands where it was.
157
204
  const preferred = pinned ?? sessionPort ?? (deps.pickPort ?? randomPort)();
158
- // The viewer's credential is minted here rather than by the server, because the note
159
- // this session leaves has to carry it: that is what a teammate reads to attach.
160
- const viewToken = mintToken();
161
- let server: BoardServer;
205
+ // Minted here rather than by the server: the note this session leaves has to carry it,
206
+ // and that is what a second session reads to attach instead of serving its own page.
207
+ const token = mintToken();
208
+ let server: BoardServer | undefined;
162
209
  try {
163
210
  server = await createBoardServer({
164
211
  // A pinned port is tried alone: falling back would leave the bookmark pointing
165
212
  // at a page that moved, which is the failure the setting exists to prevent.
166
213
  ports: pinned === undefined ? [preferred, 0] : [pinned],
167
- viewToken,
214
+ token,
168
215
  ...(deps.interval === undefined ? {} : { interval: deps.interval }),
169
216
  idleMs: deps.idleMs ?? IDLE_STOP_MS,
170
- onIdle: () => void stopIdle(ctx),
171
- page: async (view, authority) => renderBoardPage({ view, writes: authority === "write" }),
217
+ firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
218
+ onIdle: (reason) => void stopIdle(ctx, reason),
219
+ page: async (view) => renderBoardPage({ view }),
172
220
  script: BOARD_CLIENT_JS,
173
221
  view: () =>
174
222
  viewOf({
@@ -178,59 +226,65 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
178
226
  now,
179
227
  busDir: () => runtime.busDir(ctx.cwd),
180
228
  }),
181
- write: (request) => writeOf({ store, actor, now }, request),
182
229
  });
230
+ // The note and the seat: written last, on the port the server actually bound, and only if no
231
+ // other session got there first: the loser of that race attaches to the page that
232
+ // won rather than serving a second page nobody can find. A note that answers
233
+ // nobody is taken over, so a page that died between its note and its server does not
234
+ // hold the name forever, and a note this build cannot read is named rather than
235
+ // blamed on a session that does not exist.
236
+ const note: PagePointer = {
237
+ port: server.port,
238
+ token,
239
+ seat: { id: seat.id, label: seat.label },
240
+ startedAt: runtime.now(),
241
+ };
242
+ const outcome = await settlePointer(store.dir, note, deps.probe);
243
+ if (outcome.kind === "attached") {
244
+ await server.close();
245
+ return attach(outcome.pointer);
246
+ }
247
+ if (outcome.kind === "written" && outcome.took !== undefined) {
248
+ const dead = outcome.took;
249
+ ctx.ui.notify(
250
+ `board: ${dead.seat.label} on :${dead.port} does not answer — open ${urlOf(dead)} · took its note over`,
251
+ "info",
252
+ );
253
+ }
254
+ if (outcome.kind !== "written") {
255
+ await server.close();
256
+ const path = join(store.dir, PAGE_FILE);
257
+ const reason =
258
+ outcome.kind === "stale"
259
+ ? `${path} names ${outcome.pointer.seat.label} on :${outcome.pointer.port}, and nothing answers — remove it to serve this board`
260
+ : outcome.kind === "busy"
261
+ ? `${path} kept changing hands — run /board again`
262
+ : `${path} is there and cannot be read — remove it to serve this board`;
263
+ ctx.ui.notify(`board: the page did not start (${reason})`, "error");
264
+ return { kind: "failed", reason };
265
+ }
266
+ sessionPort = server.port;
267
+ running = { server, store, token };
268
+ setChip(ctx, server.port);
269
+ open(server.url);
270
+ return describe(running);
183
271
  } catch (error) {
184
- const reason = failureReason(error, pinned);
185
- ctx.ui.notify(`board: the page did not start (${reason})`, "error");
186
- return { kind: "failed", reason };
187
- }
188
- // The note is written last, on the port the server actually bound, and only if no
189
- // other session got there first: the loser of that race becomes a viewer of the
190
- // page that won rather than a second page nobody can find. A note that answers
191
- // nobody is taken over, so a page that died between its note and its server does not
192
- // hold the name forever, and a note this build cannot read is named rather than
193
- // blamed on a session that does not exist.
194
- const note: PagePointer = {
195
- port: server.port,
196
- viewToken,
197
- seat: { id: seat.id, label: seat.label },
198
- startedAt: runtime.now(),
199
- };
200
- const outcome = await settlePointer(store.dir, note, deps.probe);
201
- if (outcome.kind === "attached") {
202
- await server.close();
203
- return attach(outcome.pointer);
272
+ // A bind that failed has no server to close; a seat that could not be written does,
273
+ // and leaving it bound would hold a port no note names — invisible until shutdown.
274
+ await server?.close();
275
+ return failed(error);
204
276
  }
205
- if (outcome.kind === "written" && outcome.took !== undefined) {
206
- const dead = outcome.took;
207
- ctx.ui.notify(
208
- `board: ${dead.seat.label} on :${dead.port} does not answer — open ${viewUrlOf(dead)} · took its note over`,
209
- "info",
210
- );
211
- }
212
- if (outcome.kind !== "written") {
213
- await server.close();
214
- const path = join(store.dir, PAGE_FILE);
215
- const reason =
216
- outcome.kind === "stale"
217
- ? `${path} names ${outcome.pointer.seat.label} on :${outcome.pointer.port}, and nothing answers — remove it to serve this board`
218
- : outcome.kind === "busy"
219
- ? `${path} kept changing hands — run /board again`
220
- : `${path} is there and cannot be read — remove it to serve this board`;
221
- ctx.ui.notify(`board: the page did not start (${reason})`, "error");
222
- return { kind: "failed", reason };
223
- }
224
- sessionPort = server.port;
225
- running = { server, store, viewToken };
226
- setChip(ctx, server.port);
227
- open(server.url);
228
- return describe(running);
229
277
  };
230
278
 
231
- /** The idle grace closes the page and says so, once. */
232
- const stopIdle = async (ctx: ExtensionContext): Promise<void> => {
233
- if (await stop(ctx)) ctx.ui.notify("board: stopped — the last page was closed", "info");
279
+ /** The idle grace closes the page and says which grace it was, once. */
280
+ const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
281
+ if (!(await stop(ctx))) return;
282
+ ctx.ui.notify(
283
+ reason === "first"
284
+ ? "board: stopped — no tab ever opened; /board starts it again"
285
+ : "board: stopped — the last page was closed",
286
+ "info",
287
+ );
234
288
  };
235
289
 
236
290
  const describe = (state: { server: BoardServer; store: BoardStore }): PageStart => ({
@@ -241,9 +295,16 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
241
295
  where: state.store.where(),
242
296
  });
243
297
 
244
- /** What `/board status` answers, wherever the page is: served here, watched, or absent. */
245
- const statusOf = (): BoardPageStatus => {
246
- if (running) {
298
+ /**
299
+ * What `/board status` answers, wherever the page is: served here, watched, watched and
300
+ * dead, or absent.
301
+ *
302
+ * A watched page is probed, because the note saying it exists is not the same thing as
303
+ * it answering: after the lead's session ends, its note stays and the reader would
304
+ * otherwise be told about a port nobody holds.
305
+ */
306
+ const statusOf = async (): Promise<BoardPageStatus> => {
307
+ if (running !== undefined) {
247
308
  return {
248
309
  running: true,
249
310
  seat: seat.label,
@@ -253,27 +314,42 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
253
314
  watchers: running.server.watchers(),
254
315
  };
255
316
  }
256
- if (attachedTo !== undefined) {
257
- return {
258
- running: false,
259
- attached: true,
260
- seat: attachedTo.seat.label,
261
- port: attachedTo.port,
262
- url: viewUrlOf(attachedTo),
263
- };
264
- }
265
- return { running: false };
317
+ if (attachedTo === undefined) return { running: false };
318
+ const answers = await probePage(attachedTo, deps.probe);
319
+ return {
320
+ running: false,
321
+ attached: true,
322
+ ...(answers ? {} : { stale: true }),
323
+ seat: attachedTo.seat.label,
324
+ port: attachedTo.port,
325
+ url: urlOf(attachedTo),
326
+ };
327
+ };
328
+
329
+ /**
330
+ * Drop the note of a page that has stopped answering.
331
+ *
332
+ * Only a note this session watched, and only while it answers nobody: a page that came
333
+ * back between the status line and this call keeps its note, and `clearPointer`'s token
334
+ * check makes that true even if a new page wrote one in between.
335
+ */
336
+ const dropStale = async (ctx: ExtensionContext): Promise<boolean> => {
337
+ const pointer = attachedTo;
338
+ if (pointer === undefined) return false;
339
+ if (await probePage(pointer, deps.probe)) return false;
340
+ const store = await storeOf(ctx);
341
+ if (!(await clearPointer(store.dir, pointer.token))) return false;
342
+ attachedTo = undefined;
343
+ return true;
266
344
  };
267
345
 
268
346
  return {
269
347
  start,
270
348
  stop,
271
349
  status: statusOf,
350
+ dropStale,
272
351
  closeAll: async () => {
273
- if (!running) return;
274
- const { server } = running;
275
- running = undefined;
276
- await server.close();
352
+ await closeRunning();
277
353
  },
278
354
  };
279
355
  }
@@ -58,6 +58,15 @@ export function resultInput(details: BoardDetails): TailResultInput<Card> | unde
58
58
  */
59
59
  export function collapsedLine(theme: Theme, details: BoardDetails): string {
60
60
  const me = paint(theme, "faint", sanitizeLine(details.me.label));
61
+ const archived = details.archived;
62
+ if (archived !== undefined && archived.length > 0) {
63
+ const first = archived[0];
64
+ const label =
65
+ archived.length === 1 && first !== undefined
66
+ ? `${cardRef(first)} archived`
67
+ : `${plural(archived.length, "card")} archived`;
68
+ return `${paint(theme, "label", label)} ${SEPARATORS.item} ${me}`;
69
+ }
61
70
  if (details.dropped)
62
71
  return `${paint(theme, "label", `${cardRef(details.dropped)} dropped`)} ${SEPARATORS.item} ${me}`;
63
72
  const card = details.card;