@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
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The page's lifecycle: one server per session, started by `/board` and closed by
2
+ * The page's lifecycle: one mount per session, started by `/board` and given back by
3
3
  * `/board stop`, by the idle grace once the last tab has been gone for a minute, or by
4
4
  * `session_shutdown`.
5
5
  *
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.
6
+ * Only the lifecycle lives here — the state machine, the prefix it holds, the note that
7
+ * names it, and the three closes. The session's port belongs to `serve`, so nothing here
8
+ * picks one. What the page *reads* is `snapshot.ts`, and it writes nothing: how a request
9
+ * reaches the snapshot is `transport/`, the router knows nothing of the page's markup, and
10
+ * this is still the one place they meet.
10
11
  *
11
12
  * All state lives in this closure, not at module scope: Pi loads each extension with an
12
13
  * isolated module cache, so a module variable would be one copy per extension.
@@ -15,11 +16,10 @@
15
16
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
17
  import type { BoardStore } from "../../lib/board/store.ts";
17
18
  import { openInBrowser } from "../../lib/browser.ts";
18
- import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
19
- import { paint } from "../../lib/tui/tokens.ts";
20
19
  import type { RepeatTimer } from "../../lib/timer.ts";
21
20
  import type { BoardSession } from "../../lib/board/session.ts";
22
21
  import {
22
+ BOARD_BASE,
23
23
  clearPointer,
24
24
  PAGE_FILE,
25
25
  type PagePointer,
@@ -27,27 +27,18 @@ import {
27
27
  probePage,
28
28
  readPointer,
29
29
  settlePointer,
30
- viewUrlOf,
30
+ urlOf,
31
31
  } from "../../lib/board/page-pointer.ts";
32
- import { mintToken } from "../../lib/http/index.ts";
32
+ import { mintToken, type MountSource } from "../../lib/http/index.ts";
33
+ import { messageOf } from "../../lib/errors.ts";
34
+ import type { IdleReason } from "../../lib/http/sink.ts";
33
35
  import { join } from "node:path";
34
- 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";
41
41
 
42
- /**
43
- * A free port in the range the other loopback pages use, so a session's pages cluster.
44
- * Only the unpinned page draws one: `board.port` in `bytefly.json` states the port a
45
- * bookmarked URL keeps.
46
- */
47
- export function randomPort(): number {
48
- return 4780 + Math.floor(Math.random() * 1000);
49
- }
50
-
51
42
  /**
52
43
  * How long the page waits for a tab after the last one leaves.
53
44
  *
@@ -57,15 +48,32 @@ export function randomPort(): number {
57
48
  */
58
49
  export const IDLE_STOP_MS = 60_000;
59
50
 
51
+ /**
52
+ * How long a page may stand with no tab ever attached.
53
+ *
54
+ * `/board` opens a browser, and a browser that does not open (a swallowed failure, a
55
+ * terminal on a machine with no default browser) would otherwise leave the port bound for
56
+ * the rest of the session. Two minutes is long enough for a person to finish reading the
57
+ * transcript and click the link `/board` printed.
58
+ */
59
+ export const IDLE_FIRST_MS = 120_000;
60
+
60
61
  /** What `/board status` prints. */
61
62
  export interface BoardPageStatus {
62
63
  running: boolean;
63
64
  /** True when this session opened somebody else's page instead of serving one. */
64
65
  attached?: boolean;
66
+ /**
67
+ * True when that page no longer answers: the note is there, the process behind it is
68
+ * not, and `/board stop` may drop it.
69
+ */
70
+ stale?: boolean;
65
71
  /** The name the page serves as: a seat, not the session that opened it. */
66
72
  seat?: string;
67
73
  port?: number;
68
74
  url?: string;
75
+ /** The repository's name, so `status` says which board the page is. */
76
+ repo?: string;
69
77
  where?: string;
70
78
  /** How many tabs are attached, while this session serves the page. */
71
79
  watchers?: number;
@@ -79,68 +87,104 @@ export interface BoardPageStatus {
79
87
  * page* is a third answer a `running: false` could not express.
80
88
  */
81
89
  export type PageStart =
82
- | { kind: "served"; seat: string; port: number; url: string; where: string }
83
- | { kind: "attached"; seat: string; port: number; url: string; where: string }
90
+ | { kind: "served"; seat: string; port: number; url: string; repo: string; where: string }
91
+ | { kind: "attached"; seat: string; port: number; url: string; repo: string; where: string }
84
92
  | { kind: "failed"; reason: string };
85
93
 
86
94
  /** Test seams and the one place a caller may redirect the browser. */
87
95
  export interface BoardPageDeps {
88
96
  open?: (url: string) => void;
89
- pickPort?: () => number;
97
+ /** Where the prefix comes from: the session's server, or a suite's own. */
98
+ mount: MountSource;
99
+ /** The Pi session this page serves as, so the prefix lands on its socket. */
100
+ session: () => string;
90
101
  interval?: RepeatTimer;
91
102
  /** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
92
103
  idleMs?: number;
104
+ /** Grace for a page no tab ever attached to; defaults to {@link IDLE_FIRST_MS}. */
105
+ firstIdleMs?: number;
93
106
  /** Whether a page another session left a note about is answering; a test drives it. */
94
107
  probe?: ProbeFetch;
95
- /** How the `board` section answers; a test drives it without a config file. */
96
- config?: (cwd: string) => BoardConfig;
97
108
  }
98
109
 
99
110
  /** The page as `/board` and `session_shutdown` drive it. */
100
111
  export interface BoardPage {
101
112
  start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
102
- stop: (ctx: ExtensionContext) => Promise<boolean>;
103
- status: () => BoardPageStatus;
113
+ /** Give the prefix back; the session's server closes with its last mount. */
114
+ stop: () => Promise<boolean>;
115
+ /** What `/board status` prints: served here, watched, watched and dead, or absent. */
116
+ status: () => Promise<BoardPageStatus>;
117
+ /** Drop the note of a page that has stopped answering. False when it still answers. */
118
+ dropStale: (ctx: ExtensionContext) => Promise<boolean>;
104
119
  closeAll: () => Promise<void>;
105
120
  }
106
121
 
107
- export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {}): BoardPage {
108
- let running: { server: BoardServer; store: BoardStore; viewToken: string } | undefined;
122
+ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps): BoardPage {
123
+ let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
109
124
  /** The page another session serves, when this session opened that one instead. */
110
125
  let attachedTo: PagePointer | undefined;
111
- let sessionPort: number | undefined;
112
126
  // The page is a seat, not a session: one name and one id for its whole life, so a card
113
127
  // a browser claimed is still the seat's after the session that served it ends.
114
128
  const seat = pageActor();
115
129
 
116
130
  const storeOf = async (ctx: ExtensionContext): Promise<BoardStore> => runtime.store(ctx.cwd);
117
131
 
118
- const stop = async (ctx: ExtensionContext): Promise<boolean> => {
119
- if (!running) return false;
120
- const { server, store, viewToken } = running;
132
+ /**
133
+ * Close the page this session serves, and take its note with it.
134
+ *
135
+ * One close for both callers: `/board stop` has a context and clears the status line, and
136
+ * a clean `session_shutdown` has none. The note must leave either way — a note nobody
137
+ * answers makes every open tab reconnect forever and costs the next `/board` a probe
138
+ * and a takeover before it serves.
139
+ */
140
+ const closeRunning = async (): Promise<boolean> => {
141
+ if (running === undefined) return false;
142
+ const { server, store, token } = running;
121
143
  running = undefined;
122
144
  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);
145
+ await clearPointer(store.dir, token);
146
+ // A page that closed is no seat: the session's next card is written as itself. This is
147
+ // the one place all three closes pass — `/board stop`, the idle grace, and shutdown — so
148
+ // a page nobody is looking at cannot leave the session serving in name only.
149
+ runtime.serve(undefined);
127
150
  return true;
128
151
  };
129
152
 
153
+ const stop = (): Promise<boolean> => closeRunning();
154
+
130
155
  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();
156
+ // `/board` is how the page is summoned, and a page whose tab was closed is one
157
+ // command away from being shown again — the same answer a viewer's `/board` gives.
158
+ if (running !== undefined) {
159
+ (deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
160
+ return describe(running);
161
+ }
162
+ /** One refusal for every way a start can fail, named by what failed. */
163
+ const failed = (error: unknown): PageStart => {
164
+ const reason = error instanceof Error ? error.message : String(error);
165
+ ctx.ui.notify(`board: the page did not start (${reason})`, "error");
166
+ return { kind: "failed", reason };
167
+ };
168
+ // The board's own directory, then the page: both can fail on a path that exists and
169
+ // cannot be written, and a start that fails must leave no listener behind.
170
+ let store: BoardStore;
171
+ try {
172
+ store = await storeOf(ctx);
173
+ await store.ensure();
174
+ } catch (error) {
175
+ return failed(error);
176
+ }
134
177
  const open = (url: string) => (deps.open ?? openInBrowser)(view === "board" ? url : `${url}&view=team`);
135
178
  /** Watch the page another session serves: its note is the URL, its seat the name. */
136
179
  const attach = (pointer: PagePointer): PageStart => {
137
180
  attachedTo = pointer;
138
- open(viewUrlOf(pointer));
181
+ open(urlOf(pointer));
139
182
  return {
140
183
  kind: "attached",
141
184
  seat: pointer.seat.label,
142
185
  port: pointer.port,
143
- url: viewUrlOf(pointer),
186
+ url: urlOf(pointer),
187
+ repo: store.name(),
144
188
  where: store.where(),
145
189
  };
146
190
  };
@@ -151,86 +195,97 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
151
195
  attachedTo = undefined;
152
196
  const actor = () => seat;
153
197
  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;
157
- 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;
198
+ // Minted here rather than by the server: the note this session leaves has to carry it,
199
+ // and that is what a second session reads to attach instead of serving its own page.
200
+ const token = mintToken();
201
+ /** Whether this page has already said it cannot read the board. */
202
+ let blind = false;
203
+ // A frame the page cannot build is a dropped frame, not a crash: the ticker asks again
204
+ // in a second. Said once, because a failure that persists would otherwise become one
205
+ // notice per tick — and unsaid, a page that stopped following the board looks frozen.
206
+ const warnBlind = (error: unknown): void => {
207
+ if (blind) return;
208
+ blind = true;
209
+ ctx.ui.notify(`board: the page cannot read the board (${messageOf(error)}) — it will keep trying`, "warning");
210
+ };
162
211
  try {
163
- server = await createBoardServer({
164
- // A pinned port is tried alone: falling back would leave the bookmark pointing
165
- // at a page that moved, which is the failure the setting exists to prevent.
166
- ports: pinned === undefined ? [preferred, 0] : [pinned],
167
- viewToken,
212
+ const started = await createBoardServer({
213
+ mount: deps.mount,
214
+ session: deps.session(),
215
+ token,
168
216
  ...(deps.interval === undefined ? {} : { interval: deps.interval }),
217
+ onError: warnBlind,
169
218
  idleMs: deps.idleMs ?? IDLE_STOP_MS,
170
- onIdle: () => void stopIdle(ctx),
171
- page: async (view, authority) => renderBoardPage({ view, writes: authority === "write" }),
219
+ firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
220
+ onIdle: (reason) => void stopIdle(ctx, reason),
221
+ page: async (view) => renderBoardPage({ view, base: BOARD_BASE }),
172
222
  script: BOARD_CLIENT_JS,
173
223
  view: () =>
174
224
  viewOf({
175
225
  store,
176
226
  actor,
177
- session: () => runtime.who(ctx, ctx.cwd),
178
227
  now,
179
228
  busDir: () => runtime.busDir(ctx.cwd),
180
229
  }),
181
- write: (request) => writeOf({ store, actor, now }, request),
182
230
  });
231
+ if (started.kind === "refused") {
232
+ ctx.ui.notify(`board: the page did not start (${started.reason})`, "error");
233
+ return { kind: "failed", reason: started.reason };
234
+ }
235
+ const server = started.server;
236
+ // The note and the seat: written last, on the port the server actually bound, and only if no
237
+ // other session got there first: the loser of that race attaches to the page that
238
+ // won rather than serving a second page nobody can find. A note that answers
239
+ // nobody is taken over, so a page that died between its note and its server does not
240
+ // hold the name forever, and a note this build cannot read is named rather than
241
+ // blamed on a session that does not exist.
242
+ const note: PagePointer = {
243
+ port: server.port,
244
+ token,
245
+ seat: { id: seat.id, label: seat.label },
246
+ startedAt: runtime.now(),
247
+ };
248
+ const outcome = await settlePointer(store.dir, note, deps.probe);
249
+ if (outcome.kind === "attached") {
250
+ await server.close();
251
+ return attach(outcome.pointer);
252
+ }
253
+ if (outcome.kind === "written" && outcome.took !== undefined) {
254
+ const dead = outcome.took;
255
+ ctx.ui.notify(
256
+ `board: ${dead.seat.label} on :${dead.port} does not answer — open ${urlOf(dead)} · took its note over`,
257
+ "info",
258
+ );
259
+ }
260
+ if (outcome.kind !== "written") {
261
+ await server.close();
262
+ const path = join(store.dir, PAGE_FILE);
263
+ const reason =
264
+ outcome.kind === "stale"
265
+ ? `${path} names ${outcome.pointer.seat.label} on :${outcome.pointer.port}, and nothing answers — remove it to serve this board`
266
+ : outcome.kind === "busy"
267
+ ? `${path} kept changing hands — run /board again`
268
+ : `${path} is there and cannot be read — remove it to serve this board`;
269
+ ctx.ui.notify(`board: the page did not start (${reason})`, "error");
270
+ return { kind: "failed", reason };
271
+ }
272
+ running = { server, store, token };
273
+ open(server.url);
274
+ return describe(running);
183
275
  } 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);
276
+ return failed(error);
204
277
  }
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
278
  };
230
279
 
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");
280
+ /** The idle grace closes the page and says which grace it was, once. */
281
+ const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
282
+ if (!(await stop())) return;
283
+ ctx.ui.notify(
284
+ reason === "first"
285
+ ? "board: stopped — no tab ever opened; /board starts it again"
286
+ : "board: stopped — the last page was closed",
287
+ "info",
288
+ );
234
289
  };
235
290
 
236
291
  const describe = (state: { server: BoardServer; store: BoardStore }): PageStart => ({
@@ -238,75 +293,66 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
238
293
  seat: seat.label,
239
294
  port: state.server.port,
240
295
  url: state.server.url,
296
+ repo: state.store.name(),
241
297
  where: state.store.where(),
242
298
  });
243
299
 
244
- /** What `/board status` answers, wherever the page is: served here, watched, or absent. */
245
- const statusOf = (): BoardPageStatus => {
246
- if (running) {
300
+ /**
301
+ * What `/board status` answers, wherever the page is: served here, watched, watched and
302
+ * dead, or absent.
303
+ *
304
+ * A watched page is probed, because the note saying it exists is not the same thing as
305
+ * it answering: after the lead's session ends, its note stays and the reader would
306
+ * otherwise be told about a port nobody holds.
307
+ */
308
+ const statusOf = async (): Promise<BoardPageStatus> => {
309
+ if (running !== undefined) {
247
310
  return {
248
311
  running: true,
249
312
  seat: seat.label,
250
313
  port: running.server.port,
251
314
  url: running.server.url,
315
+ repo: running.store.name(),
252
316
  where: running.store.where(),
253
317
  watchers: running.server.watchers(),
254
318
  };
255
319
  }
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 };
320
+ if (attachedTo === undefined) return { running: false };
321
+ const answers = await probePage(attachedTo, deps.probe);
322
+ return {
323
+ running: false,
324
+ attached: true,
325
+ ...(answers ? {} : { stale: true }),
326
+ seat: attachedTo.seat.label,
327
+ port: attachedTo.port,
328
+ url: urlOf(attachedTo),
329
+ };
330
+ };
331
+
332
+ /**
333
+ * Drop the note of a page that has stopped answering.
334
+ *
335
+ * Only a note this session watched, and only while it answers nobody: a page that came
336
+ * back between the status line and this call keeps its note, and `clearPointer`'s token
337
+ * check makes that true even if a new page wrote one in between.
338
+ */
339
+ const dropStale = async (ctx: ExtensionContext): Promise<boolean> => {
340
+ const pointer = attachedTo;
341
+ if (pointer === undefined) return false;
342
+ if (await probePage(pointer, deps.probe)) return false;
343
+ const store = await storeOf(ctx);
344
+ if (!(await clearPointer(store.dir, pointer.token))) return false;
345
+ attachedTo = undefined;
346
+ return true;
266
347
  };
267
348
 
268
349
  return {
269
350
  start,
270
351
  stop,
271
352
  status: statusOf,
353
+ dropStale,
272
354
  closeAll: async () => {
273
- if (!running) return;
274
- const { server } = running;
275
- running = undefined;
276
- await server.close();
355
+ await closeRunning();
277
356
  },
278
357
  };
279
358
  }
280
-
281
- /**
282
- * Why a start failed, naming the setting when the pinned port was the reason: a
283
- * bind error's own text says the address, not the key that chose it.
284
- */
285
- function failureReason(error: unknown, pinned: number | undefined): string {
286
- // The bind path rejects with the `listen` error, so `code` is there to read.
287
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
288
- if (pinned !== undefined && code === "EADDRINUSE") {
289
- return `port ${pinned} is in use — change "port" in the bytefly.json board section, or stop what answers on it`;
290
- }
291
- return error instanceof Error ? error.message : String(error);
292
- }
293
-
294
- /** The chip naming the port, cleared with an empty text. */
295
- function setChip(ctx: ExtensionContext, port: number): void {
296
- try {
297
- ctx.ui.setStatus(
298
- STATUS_KEYS.board,
299
- `${paint(ctx.ui.theme, "ok", GLYPHS.board)} ${paint(ctx.ui.theme, "accent", String(port))}`,
300
- );
301
- } catch {
302
- // A host without a status line is not a reason to keep the page from starting.
303
- }
304
- }
305
-
306
- function clearChip(ctx: ExtensionContext): void {
307
- try {
308
- ctx.ui.setStatus(STATUS_KEYS.board, undefined);
309
- } catch {
310
- // Same as above: nothing to clear.
311
- }
312
- }
@@ -8,11 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import type { Component } from "@earendil-works/pi-tui";
12
11
  import { type Card, cardRef } from "../../lib/board/card.ts";
13
12
  import { sanitizeLine } from "../../lib/format.ts";
14
13
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
15
- import { TailResult, type TailResultInput, type TailSection } from "../../lib/tui/tool.ts";
14
+ import type { TailResultInput, TailSection } from "../../lib/tui/tool.ts";
16
15
  import { plural } from "../../lib/board/round-text.ts";
17
16
  import { paint } from "../../lib/tui/tokens.ts";
18
17
  import { boardRowLine } from "./rows.ts";
@@ -58,6 +57,15 @@ export function resultInput(details: BoardDetails): TailResultInput<Card> | unde
58
57
  */
59
58
  export function collapsedLine(theme: Theme, details: BoardDetails): string {
60
59
  const me = paint(theme, "faint", sanitizeLine(details.me.label));
60
+ const archived = details.archived;
61
+ if (archived !== undefined && archived.length > 0) {
62
+ const first = archived[0];
63
+ const label =
64
+ archived.length === 1 && first !== undefined
65
+ ? `${cardRef(first)} archived`
66
+ : `${plural(archived.length, "card")} archived`;
67
+ return `${paint(theme, "label", label)} ${SEPARATORS.item} ${me}`;
68
+ }
61
69
  if (details.dropped)
62
70
  return `${paint(theme, "label", `${cardRef(details.dropped)} dropped`)} ${SEPARATORS.item} ${me}`;
63
71
  const card = details.card;
@@ -75,25 +83,3 @@ function cardLine(theme: Theme, card: Card): string {
75
83
  const lane = paint(theme, "accent", card.status);
76
84
  return `${id} ${lane} ${SEPARATORS.item} ${paint(theme, "label", sanitizeLine(card.title))}`;
77
85
  }
78
-
79
- /** The expanded block, as a component the transcript can reuse across renders. */
80
- export class BoardResult implements Component {
81
- private readonly view: TailResult<Card>;
82
-
83
- constructor(input: TailResultInput<Card>, theme: Theme) {
84
- this.view = new TailResult(input, theme);
85
- }
86
-
87
- /** Update in place so the transcript reuses this component. */
88
- setInput(input: TailResultInput<Card>, theme: Theme): void {
89
- this.view.setInput(input, theme);
90
- }
91
-
92
- invalidate(): void {
93
- this.view.invalidate();
94
- }
95
-
96
- render(width: number): string[] {
97
- return this.view.render(width);
98
- }
99
- }
@@ -35,7 +35,7 @@ function stateOf(card: Card): string[] {
35
35
  }
36
36
 
37
37
  /** One card: the id and title, with its state trailing. */
38
- export function cardRowLine(card: Card, width: number, theme: Theme, options: { self?: boolean } = {}): string {
38
+ function cardRowLine(card: Card, width: number, theme: Theme, options: { self?: boolean } = {}): string {
39
39
  return listRow(theme, width, {
40
40
  marker: false,
41
41
  label: `${cardRef(card.id)} ${sanitizeLine(card.title)}`,
@@ -8,31 +8,40 @@
8
8
  */
9
9
 
10
10
  import { Type } from "typebox";
11
- import { CARD_STATUSES, PRIORITIES } from "../../lib/board/card.ts";
11
+ import { CARD_STATUSES, MAX_NOTE, PRIORITIES } from "../../lib/board/card.ts";
12
12
  import { BOARD_ACTIONS } from "./types.ts";
13
13
 
14
14
  export const BoardParams = Type.Object({
15
15
  action: Type.Union(
16
16
  BOARD_ACTIONS.map((action) => Type.Literal(action)),
17
17
  {
18
+ // The verbs and their meanings live in the tool description, built from
19
+ // `BOARD_ACTION_DOCS`; this says only what a caller needs to choose one.
18
20
  description:
19
- "`list` the board (optionally one card, one lane, a query, or the lead's rollup), `add` a card, " +
20
- "`claim` one for this session, `release` it, `reclaim` it from whoever holds it, `move` it to " +
21
- "another lane, `note` it, `plan` it (record the plan file being executed), `edit` what it says " +
22
- "(title, paths, priority, what it waits for), or `drop` it.",
21
+ "`list` reads the board, or one card when `card` names it; every other verb changes it. The tool description says what each one does.",
23
22
  },
24
23
  ),
25
24
  card: Type.Optional(
26
- Type.String({ description: 'The card id for one card, e.g. "4" or "#4". Used by every action but `list`/`add`.' }),
25
+ Type.String({
26
+ description:
27
+ 'The card id for one card, e.g. "4" or "#4". `list` reads that card; every other action but `add` acts on it.',
28
+ }),
27
29
  ),
28
30
  status: Type.Optional(
29
31
  Type.Union(
30
32
  CARD_STATUSES.map((status) => Type.Literal(status)),
31
33
  {
32
- description: "`list`: show only this lane. `move`: the lane to move to (not `todo` — use `release`).",
34
+ description:
35
+ "`list`: show only this lane, and not skip the `done` cards. `move`: the lane to move to (not `todo` — use `release`).",
33
36
  },
34
37
  ),
35
38
  ),
39
+ blockedBy: Type.Optional(
40
+ Type.String({
41
+ description:
42
+ "`move` to `blocked`: what the card waits on — the decision a person has to make, one line, at most 200 characters.",
43
+ }),
44
+ ),
36
45
  title: Type.Optional(Type.String({ description: "`add` and `edit`: the feature's outcome, one line." })),
37
46
  paths: Type.Optional(
38
47
  Type.Array(Type.String(), {
@@ -40,8 +49,14 @@ export const BoardParams = Type.Object({
40
49
  "`add` and `edit`: repository-relative hints (directories or files) where the work happens. Not claims.",
41
50
  }),
42
51
  ),
43
- note: Type.Optional(Type.String({ description: "`add`: a first note, e.g. why the card exists." })),
44
- text: Type.Optional(Type.String({ description: "`note`: the note to append." })),
52
+ note: Type.Optional(
53
+ Type.String({
54
+ description: `\`add\`: a first note, e.g. why the card exists; one line, at most ${MAX_NOTE} characters.`,
55
+ }),
56
+ ),
57
+ text: Type.Optional(
58
+ Type.String({ description: `\`note\`: the note to append; one line, at most ${MAX_NOTE} characters.` }),
59
+ ),
45
60
  path: Type.Optional(Type.String({ description: "`plan`: the plan file this card is executing." })),
46
61
  priority: Type.Optional(
47
62
  Type.Union(
@@ -64,7 +79,7 @@ export const BoardParams = Type.Object({
64
79
  query: Type.Optional(
65
80
  Type.String({
66
81
  description:
67
- "`list`: show only the cards whose title, path hint, or holder matches this text, case-insensitively.",
82
+ "`list`: show only the cards whose title, holder, path hint, plan, or a note's text matches this text, case-insensitively.",
68
83
  }),
69
84
  ),
70
85
  round: Type.Optional(
@@ -72,4 +87,10 @@ export const BoardParams = Type.Object({
72
87
  description: "`list`: also return the lead's rollup — owners, unassigned, unplanned, blocked, idle.",
73
88
  }),
74
89
  ),
90
+ archived: Type.Optional(
91
+ Type.Boolean({
92
+ description:
93
+ "`list`: read the archive instead of the board — the finished cards `archive` set aside, which no other read opens. `query` and `mine` narrow it; `status` and `round` do not apply to a pile that is out of the board.",
94
+ }),
95
+ ),
75
96
  });