@gavin-hu/my-pi-agent 0.20.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 (106) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/extensions/README.md +1 -0
  4. package/docs/extensions/board.md +57 -42
  5. package/docs/extensions/bytefly.md +85 -89
  6. package/docs/extensions/file-browse.md +36 -41
  7. package/docs/extensions/plan.md +31 -14
  8. package/docs/extensions/serve.md +129 -0
  9. package/docs/extensions/session-bus.md +1 -1
  10. package/docs/extensions/team.md +11 -11
  11. package/extensions/board/commands.ts +27 -14
  12. package/extensions/board/index.ts +14 -2
  13. package/extensions/board/join.ts +31 -10
  14. package/extensions/board/page.ts +53 -83
  15. package/extensions/board/result.ts +1 -24
  16. package/extensions/board/rows.ts +1 -1
  17. package/extensions/board/schema.ts +9 -3
  18. package/extensions/board/snapshot.ts +7 -6
  19. package/extensions/board/tools.ts +27 -15
  20. package/extensions/board/transport/router.ts +3 -5
  21. package/extensions/board/transport/server.ts +59 -28
  22. package/extensions/board/types.ts +0 -10
  23. package/extensions/board/view/client.ts +3 -2
  24. package/extensions/board/view/live.ts +1 -7
  25. package/extensions/board/view/page.ts +16 -5
  26. package/extensions/board/view/render.ts +22 -17
  27. package/extensions/board/view/styles.ts +7 -1
  28. package/extensions/bytefly/footer/config.ts +3 -6
  29. package/extensions/bytefly/footer/render/footer.ts +18 -4
  30. package/extensions/bytefly/footer/render/key.ts +46 -0
  31. package/extensions/bytefly/footer/render/layout.ts +13 -3
  32. package/extensions/bytefly/footer/render/lines.ts +4 -4
  33. package/extensions/bytefly/footer/render/segments.ts +10 -34
  34. package/extensions/bytefly/header/component.ts +4 -20
  35. package/extensions/check/render.ts +10 -1
  36. package/extensions/file-browse/config/config.ts +4 -19
  37. package/extensions/file-browse/index.ts +27 -48
  38. package/extensions/file-browse/live/live.ts +1 -1
  39. package/extensions/file-browse/transport/router.ts +12 -4
  40. package/extensions/file-browse/transport/server.ts +61 -28
  41. package/extensions/file-browse/view/chrome.ts +4 -4
  42. package/extensions/file-browse/view/client.ts +3 -1
  43. package/extensions/file-browse/view/html.ts +73 -40
  44. package/extensions/file-browse/view/live-client.ts +2 -2
  45. package/extensions/file-browse/view/markdown.ts +13 -14
  46. package/extensions/file-browse/view/palette.ts +2 -2
  47. package/extensions/file-browse/view/raw-html.ts +9 -9
  48. package/extensions/file-browse/view/tree.ts +1 -1
  49. package/extensions/file-browse/view/urls.ts +20 -6
  50. package/extensions/goal/tui.ts +12 -2
  51. package/extensions/job/tools.ts +19 -7
  52. package/extensions/job/tui.ts +2 -28
  53. package/extensions/plan/commands.ts +2 -2
  54. package/extensions/plan/feedback.ts +62 -16
  55. package/extensions/plan/index.ts +7 -1
  56. package/extensions/plan/plan-text.ts +114 -0
  57. package/extensions/plan/runtime.ts +12 -2
  58. package/extensions/plan/tools.ts +2 -2
  59. package/extensions/plan/web-blocks.ts +76 -5
  60. package/extensions/plan/web-chrome.ts +4 -0
  61. package/extensions/plan/web-client.ts +99 -2
  62. package/extensions/plan/web-collab.ts +4 -4
  63. package/extensions/plan/web-comments.ts +39 -8
  64. package/extensions/plan/web-composer.ts +14 -3
  65. package/extensions/plan/web-keys.ts +149 -25
  66. package/extensions/plan/web-markdown.ts +1 -1
  67. package/extensions/plan/web-outline.ts +22 -2
  68. package/extensions/plan/web-page.ts +56 -86
  69. package/extensions/plan/web-payload.ts +188 -0
  70. package/extensions/plan/web-plan.ts +23 -13
  71. package/extensions/plan/web-settle.ts +14 -7
  72. package/extensions/plan/web-share-session.ts +24 -55
  73. package/extensions/plan/web-share.ts +19 -37
  74. package/extensions/plan/web-write.ts +112 -0
  75. package/extensions/plan/web.ts +77 -130
  76. package/extensions/serve/config.ts +37 -0
  77. package/extensions/serve/index.ts +71 -0
  78. package/extensions/serve/registry.ts +251 -0
  79. package/extensions/session-bus/join-request.ts +16 -9
  80. package/extensions/session-bus/result.ts +1 -27
  81. package/extensions/session-bus/tools.ts +5 -4
  82. package/extensions/team/commands.ts +3 -1
  83. package/extensions/todo/tui.ts +21 -3
  84. package/lib/README.md +5 -4
  85. package/lib/board/page-pointer.ts +29 -4
  86. package/lib/board/session.ts +84 -18
  87. package/lib/board/store.ts +34 -8
  88. package/lib/board/view.ts +20 -14
  89. package/lib/bus-request.ts +83 -0
  90. package/lib/http/index.ts +11 -7
  91. package/lib/http/mount.ts +142 -0
  92. package/lib/http/port.ts +25 -0
  93. package/lib/http/serve.ts +170 -46
  94. package/lib/http/server.ts +3 -3
  95. package/lib/plan-entry.ts +9 -13
  96. package/lib/requirements.ts +2 -2
  97. package/lib/session-label.ts +23 -7
  98. package/lib/session-tool.ts +37 -21
  99. package/lib/tui/README.md +6 -3
  100. package/lib/tui/glyphs.ts +2 -4
  101. package/lib/tui/render-cache.ts +29 -0
  102. package/lib/tui/tool.ts +30 -1
  103. package/lib/web/client-runtime.ts +27 -1
  104. package/lib/web/markup.ts +18 -1
  105. package/package.json +3 -2
  106. package/extensions/board/config.ts +0 -36
@@ -2,30 +2,51 @@
2
2
  * Board's side of the team: the one call that puts this session on it.
3
3
  *
4
4
  * The user's surface is `/board` and `team`; the session bus is the transport under
5
- * them. So the board joins on the user's behalf — when the page opens, and again the
6
- * first time a card is worked — and never asks anybody to run a bus action.
5
+ * them. So the board joins on the user's behalf — when the page opens, and the first
6
+ * time this session claims a card — and never asks anybody to run a bus action.
7
+ *
8
+ * The label it asks for is the session's own team name (`BoardSession.who`), so the
9
+ * card, the bus record, and the roster say one word, and joining never renames the
10
+ * session: the footer keeps the name the user gave it.
7
11
  *
8
12
  * The seam exists for the same reason `team`'s dispatch does: a board suite drives a
9
13
  * recording join instead of a bus, and the shipped implementation is one in-process
10
14
  * request through `lib/session-tool.ts` (a command context cannot call a tool).
11
15
  */
12
16
 
13
- import type { JoinResult } from "../../lib/session-tool.ts";
14
- import { DEFAULT_SEAT } from "./seat.ts";
17
+ import { messageOf } from "../../lib/errors.ts";
18
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import type { JoinResult, TeamJoin } from "../../lib/session-tool.ts";
20
+ import type { TeamStanding } from "../../lib/board/session.ts";
15
21
 
16
22
  /**
17
23
  * The join itself is `lib/session-tool.ts`'s, because `team` asks for it too; this
18
- * module only holds what is the board's — the label it lands on, and how the outcome
19
- * reads on a `/board` line.
24
+ * module only holds what is the board's — when it asks, and how the outcome reads on a
25
+ * `/board` line.
20
26
  */
21
27
  export type { TeamJoin } from "../../lib/session-tool.ts";
22
28
 
23
29
  /**
24
- * The label `/board` lands on: the page's seat when this session serves it, and
25
- * nothing when it opened somebody else's page, so a session keeps its own name.
30
+ * Put this session on the team, once: the first card it claims.
31
+ *
32
+ * Asked only while the standing does not say this session is on the team, so a card
33
+ * write costs one request for the session rather than one per card. A failure is
34
+ * recorded and asked again by the next claim; a bus that is not loaded answers with a
35
+ * sentence, which is a result and not an exception — and a join that did throw still
36
+ * must not undo the card that was already written, so it is swallowed here.
26
37
  */
27
- export function joinLabel(served: boolean): string | undefined {
28
- return served ? DEFAULT_SEAT : undefined;
38
+ export async function joinOnClaim(
39
+ standing: TeamStanding,
40
+ team: TeamJoin,
41
+ ctx: ExtensionContext,
42
+ label: string,
43
+ ): Promise<void> {
44
+ if (standing.last()?.ok === true) return;
45
+ try {
46
+ standing.record(await team.join(ctx, { label }));
47
+ } catch (error) {
48
+ standing.record({ ok: false, error: messageOf(error) });
49
+ }
29
50
  }
30
51
 
31
52
  /**
@@ -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`, 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.
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,
@@ -29,25 +29,16 @@ import {
29
29
  settlePointer,
30
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";
33
34
  import type { IdleReason } from "../../lib/http/sink.ts";
34
35
  import { join } from "node:path";
35
- import { type BoardConfig, loadBoardConfig } from "./config.ts";
36
36
  import { pageActor } from "./seat.ts";
37
37
  import { viewOf } from "./snapshot.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
  *
@@ -81,6 +72,8 @@ export interface BoardPageStatus {
81
72
  seat?: string;
82
73
  port?: number;
83
74
  url?: string;
75
+ /** The repository's name, so `status` says which board the page is. */
76
+ repo?: string;
84
77
  where?: string;
85
78
  /** How many tabs are attached, while this session serves the page. */
86
79
  watchers?: number;
@@ -94,14 +87,17 @@ export interface BoardPageStatus {
94
87
  * page* is a third answer a `running: false` could not express.
95
88
  */
96
89
  export type PageStart =
97
- | { kind: "served"; seat: string; port: number; url: string; where: string }
98
- | { 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 }
99
92
  | { kind: "failed"; reason: string };
100
93
 
101
94
  /** Test seams and the one place a caller may redirect the browser. */
102
95
  export interface BoardPageDeps {
103
96
  open?: (url: string) => void;
104
- 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;
105
101
  interval?: RepeatTimer;
106
102
  /** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
107
103
  idleMs?: number;
@@ -109,14 +105,13 @@ export interface BoardPageDeps {
109
105
  firstIdleMs?: number;
110
106
  /** Whether a page another session left a note about is answering; a test drives it. */
111
107
  probe?: ProbeFetch;
112
- /** How the `board` section answers; a test drives it without a config file. */
113
- config?: (cwd: string) => BoardConfig;
114
108
  }
115
109
 
116
110
  /** The page as `/board` and `session_shutdown` drive it. */
117
111
  export interface BoardPage {
118
112
  start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
119
- stop: (ctx: ExtensionContext) => Promise<boolean>;
113
+ /** Give the prefix back; the session's server closes with its last mount. */
114
+ stop: () => Promise<boolean>;
120
115
  /** What `/board status` prints: served here, watched, watched and dead, or absent. */
121
116
  status: () => Promise<BoardPageStatus>;
122
117
  /** Drop the note of a page that has stopped answering. False when it still answers. */
@@ -124,11 +119,10 @@ export interface BoardPage {
124
119
  closeAll: () => Promise<void>;
125
120
  }
126
121
 
127
- export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {}): BoardPage {
122
+ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps): BoardPage {
128
123
  let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
129
124
  /** The page another session serves, when this session opened that one instead. */
130
125
  let attachedTo: PagePointer | undefined;
131
- let sessionPort: number | undefined;
132
126
  // The page is a seat, not a session: one name and one id for its whole life, so a card
133
127
  // a browser claimed is still the seat's after the session that served it ends.
134
128
  const seat = pageActor();
@@ -138,22 +132,25 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
138
132
  /**
139
133
  * Close the page this session serves, and take its note with it.
140
134
  *
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
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
143
137
  * answers makes every open tab reconnect forever and costs the next `/board` a probe
144
138
  * and a takeover before it serves.
145
139
  */
146
- const closeRunning = async (ctx?: ExtensionContext): Promise<boolean> => {
140
+ const closeRunning = async (): Promise<boolean> => {
147
141
  if (running === undefined) return false;
148
142
  const { server, store, token } = running;
149
143
  running = undefined;
150
144
  await server.close();
151
145
  await clearPointer(store.dir, token);
152
- if (ctx !== undefined) clearChip(ctx);
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);
153
150
  return true;
154
151
  };
155
152
 
156
- const stop = (ctx: ExtensionContext): Promise<boolean> => closeRunning(ctx);
153
+ const stop = (): Promise<boolean> => closeRunning();
157
154
 
158
155
  const start = async (ctx: ExtensionContext, view: PageView = "board"): Promise<PageStart> => {
159
156
  // `/board` is how the page is summoned, and a page whose tab was closed is one
@@ -162,11 +159,9 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
162
159
  (deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
163
160
  return describe(running);
164
161
  }
165
- const config = (deps.config ?? loadBoardConfig)(ctx.cwd);
166
- const pinned = config.port > 0 ? config.port : undefined;
167
162
  /** One refusal for every way a start can fail, named by what failed. */
168
163
  const failed = (error: unknown): PageStart => {
169
- const reason = failureReason(error, pinned);
164
+ const reason = error instanceof Error ? error.message : String(error);
170
165
  ctx.ui.notify(`board: the page did not start (${reason})`, "error");
171
166
  return { kind: "failed", reason };
172
167
  };
@@ -189,6 +184,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
189
184
  seat: pointer.seat.label,
190
185
  port: pointer.port,
191
186
  url: urlOf(pointer),
187
+ repo: store.name(),
192
188
  where: store.where(),
193
189
  };
194
190
  };
@@ -199,34 +195,44 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
199
195
  attachedTo = undefined;
200
196
  const actor = () => seat;
201
197
  const now = () => runtime.now();
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.
204
- const preferred = pinned ?? sessionPort ?? (deps.pickPort ?? randomPort)();
205
198
  // Minted here rather than by the server: the note this session leaves has to carry it,
206
199
  // and that is what a second session reads to attach instead of serving its own page.
207
200
  const token = mintToken();
208
- let server: BoardServer | undefined;
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
+ };
209
211
  try {
210
- server = await createBoardServer({
211
- // A pinned port is tried alone: falling back would leave the bookmark pointing
212
- // at a page that moved, which is the failure the setting exists to prevent.
213
- ports: pinned === undefined ? [preferred, 0] : [pinned],
212
+ const started = await createBoardServer({
213
+ mount: deps.mount,
214
+ session: deps.session(),
214
215
  token,
215
216
  ...(deps.interval === undefined ? {} : { interval: deps.interval }),
217
+ onError: warnBlind,
216
218
  idleMs: deps.idleMs ?? IDLE_STOP_MS,
217
219
  firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
218
220
  onIdle: (reason) => void stopIdle(ctx, reason),
219
- page: async (view) => renderBoardPage({ view }),
221
+ page: async (view) => renderBoardPage({ view, base: BOARD_BASE }),
220
222
  script: BOARD_CLIENT_JS,
221
223
  view: () =>
222
224
  viewOf({
223
225
  store,
224
226
  actor,
225
- session: () => runtime.who(ctx, ctx.cwd),
226
227
  now,
227
228
  busDir: () => runtime.busDir(ctx.cwd),
228
229
  }),
229
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;
230
236
  // The note and the seat: written last, on the port the server actually bound, and only if no
231
237
  // other session got there first: the loser of that race attaches to the page that
232
238
  // won rather than serving a second page nobody can find. A note that answers
@@ -263,22 +269,17 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
263
269
  ctx.ui.notify(`board: the page did not start (${reason})`, "error");
264
270
  return { kind: "failed", reason };
265
271
  }
266
- sessionPort = server.port;
267
272
  running = { server, store, token };
268
- setChip(ctx, server.port);
269
273
  open(server.url);
270
274
  return describe(running);
271
275
  } catch (error) {
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
276
  return failed(error);
276
277
  }
277
278
  };
278
279
 
279
280
  /** The idle grace closes the page and says which grace it was, once. */
280
281
  const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
281
- if (!(await stop(ctx))) return;
282
+ if (!(await stop())) return;
282
283
  ctx.ui.notify(
283
284
  reason === "first"
284
285
  ? "board: stopped — no tab ever opened; /board starts it again"
@@ -292,6 +293,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
292
293
  seat: seat.label,
293
294
  port: state.server.port,
294
295
  url: state.server.url,
296
+ repo: state.store.name(),
295
297
  where: state.store.where(),
296
298
  });
297
299
 
@@ -310,6 +312,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
310
312
  seat: seat.label,
311
313
  port: running.server.port,
312
314
  url: running.server.url,
315
+ repo: running.store.name(),
313
316
  where: running.store.where(),
314
317
  watchers: running.server.watchers(),
315
318
  };
@@ -353,36 +356,3 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
353
356
  },
354
357
  };
355
358
  }
356
-
357
- /**
358
- * Why a start failed, naming the setting when the pinned port was the reason: a
359
- * bind error's own text says the address, not the key that chose it.
360
- */
361
- function failureReason(error: unknown, pinned: number | undefined): string {
362
- // The bind path rejects with the `listen` error, so `code` is there to read.
363
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
364
- if (pinned !== undefined && code === "EADDRINUSE") {
365
- return `port ${pinned} is in use — change "port" in the bytefly.json board section, or stop what answers on it`;
366
- }
367
- return error instanceof Error ? error.message : String(error);
368
- }
369
-
370
- /** The chip naming the port, cleared with an empty text. */
371
- function setChip(ctx: ExtensionContext, port: number): void {
372
- try {
373
- ctx.ui.setStatus(
374
- STATUS_KEYS.board,
375
- `${paint(ctx.ui.theme, "ok", GLYPHS.board)} ${paint(ctx.ui.theme, "accent", String(port))}`,
376
- );
377
- } catch {
378
- // A host without a status line is not a reason to keep the page from starting.
379
- }
380
- }
381
-
382
- function clearChip(ctx: ExtensionContext): void {
383
- try {
384
- ctx.ui.setStatus(STATUS_KEYS.board, undefined);
385
- } catch {
386
- // Same as above: nothing to clear.
387
- }
388
- }
@@ -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";
@@ -84,25 +83,3 @@ function cardLine(theme: Theme, card: Card): string {
84
83
  const lane = paint(theme, "accent", card.status);
85
84
  return `${id} ${lane} ${SEPARATORS.item} ${paint(theme, "label", sanitizeLine(card.title))}`;
86
85
  }
87
-
88
- /** The expanded block, as a component the transcript can reuse across renders. */
89
- export class BoardResult implements Component {
90
- private readonly view: TailResult<Card>;
91
-
92
- constructor(input: TailResultInput<Card>, theme: Theme) {
93
- this.view = new TailResult(input, theme);
94
- }
95
-
96
- /** Update in place so the transcript reuses this component. */
97
- setInput(input: TailResultInput<Card>, theme: Theme): void {
98
- this.view.setInput(input, theme);
99
- }
100
-
101
- invalidate(): void {
102
- this.view.invalidate();
103
- }
104
-
105
- render(width: number): string[] {
106
- return this.view.render(width);
107
- }
108
- }
@@ -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,7 +8,7 @@
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({
@@ -49,8 +49,14 @@ export const BoardParams = Type.Object({
49
49
  "`add` and `edit`: repository-relative hints (directories or files) where the work happens. Not claims.",
50
50
  }),
51
51
  ),
52
- note: Type.Optional(Type.String({ description: "`add`: a first note, e.g. why the card exists." })),
53
- 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
+ ),
54
60
  path: Type.Optional(Type.String({ description: "`plan`: the plan file this card is executing." })),
55
61
  priority: Type.Optional(
56
62
  Type.Union(
@@ -18,8 +18,6 @@ export interface SnapshotContext {
18
18
  store: BoardStore;
19
19
  /** The actor every request is made and reported as: the page's seat. */
20
20
  actor: () => Actor;
21
- /** The session serving the page, which is who is reading, not who it writes as. */
22
- session?: () => Actor;
23
21
  now: () => number;
24
22
  /** Where this repository's bus files its records. */
25
23
  busDir: () => Promise<string>;
@@ -28,14 +26,17 @@ export interface SnapshotContext {
28
26
  /** The snapshot the page renders, and the body of `/api/board`. */
29
27
  export async function viewOf(context: SnapshotContext): Promise<BoardView> {
30
28
  const roster = await readRoster(await context.busDir());
31
- const session = context.session?.();
29
+ // One read of the pile answers both: a frame that asked `list` and `skipped` separately
30
+ // would parse every card twice, once a second, for as long as a tab is open.
31
+ const { cards, skipped } = await context.store.scan();
32
32
  return buildView({
33
33
  me: context.actor(),
34
- ...(session === undefined ? {} : { session }),
35
- cards: await context.store.list(),
34
+ cards,
36
35
  now: context.now(),
37
36
  bytes: await context.store.bytes(),
38
- skipped: (await context.store.skipped()).length,
37
+ skipped: skipped.length,
38
+ repo: context.store.name(),
39
+ root: context.store.root(),
39
40
  where: context.store.where(),
40
41
  ...(roster === undefined ? {} : { roster }),
41
42
  });
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import type { Static } from "typebox";
17
- import { cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
17
+ import { type Card, cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
18
18
  import { indexOf } from "../../lib/board/links.ts";
19
19
  import { type CardOp, holds } from "../../lib/board/rules.ts";
20
20
  import { buildRound } from "../../lib/board/round.ts";
@@ -25,7 +25,7 @@ import { strayField } from "../../lib/stray-fields.ts";
25
25
  import { createExecRunner } from "../../lib/git/runner.ts";
26
26
  import { failedResult, textResult } from "../../lib/tool-result.ts";
27
27
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
28
- import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
28
+ import { callLine, detailsOrLine, isTailResult, TailResult } from "../../lib/tui/tool.ts";
29
29
  import { formatRound } from "../../lib/board/round-text.ts";
30
30
  import {
31
31
  archiveNote,
@@ -37,7 +37,8 @@ import {
37
37
  formatSkipped,
38
38
  } from "./format.ts";
39
39
  import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
40
- import { BoardResult, collapsedLine, resultInput } from "./result.ts";
40
+ import { joinOnClaim } from "./join.ts";
41
+ import { collapsedLine, resultInput } from "./result.ts";
41
42
  import { createInput } from "./input.ts";
42
43
  import type { BoardSession } from "../../lib/board/session.ts";
43
44
  import { BoardParams } from "./schema.ts";
@@ -45,7 +46,6 @@ import {
45
46
  ACTION_FIELDS,
46
47
  BOARD_ACTION_DOCS,
47
48
  BOARD_ACTIONS,
48
- BOARD_MUTATING_ACTIONS,
49
49
  BOARD_TOOL,
50
50
  type BoardAction,
51
51
  type BoardDetails,
@@ -96,6 +96,17 @@ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, c
96
96
  });
97
97
  }
98
98
 
99
+ /**
100
+ * Whether a result this tool built is a refusal.
101
+ *
102
+ * `refusal` marks one with `isError`, and the success shape (`ToolResult`) has no such
103
+ * field, so this is the one probe that tells the two apart — read here rather than
104
+ * widening the shared envelope with a flag that would then be optional everywhere.
105
+ */
106
+ function refused(result: unknown): boolean {
107
+ return typeof result === "object" && result !== null && (result as { isError?: unknown }).isError === true;
108
+ }
109
+
99
110
  /**
100
111
  * The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
101
112
  *
@@ -149,13 +160,6 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
149
160
  // have enlisted this session as a teammate on its way to the refusal.
150
161
  const stray = strayField(action, args, ACTION_FIELDS);
151
162
  if (stray !== undefined) return refusal(action, me, stray);
152
- // Working a card makes this session a teammate, once: the board is the user's
153
- // surface for the team, so nobody has to know the bus exists. A failed join is
154
- // not a failed card write — the board works with no bus at all — and it is not
155
- // marked, so the next write asks again.
156
- if (BOARD_MUTATING_ACTIONS.has(action) && !runtime.joined()) {
157
- if ((await team.join(ctx)).ok) runtime.markJoined();
158
- }
159
163
  const store = await runtime.store(ctx.cwd);
160
164
  const now = runtime.now();
161
165
  switch (action) {
@@ -163,8 +167,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
163
167
  return await read(store, action, me, args, now);
164
168
  case "add":
165
169
  return await add(store, me, args, now);
166
- case "claim":
167
- return await mutate(store, action, me, now, args, { kind: "claim" });
170
+ case "claim": {
171
+ const claimed = await mutate(store, action, me, now, args, { kind: "claim" });
172
+ // Taking a card is what makes this session a teammate. It is the one action here
173
+ // worth being addressed about, and the board is the user's surface for the team, so
174
+ // nobody has to know the bus exists. A refused claim wrote nothing and enlists
175
+ // nobody; a refused join is not a failed claim, so the card stands and the next
176
+ // claim asks again.
177
+ if (!refused(claimed)) await joinOnClaim(runtime.team, team, ctx, me.label);
178
+ return claimed;
179
+ }
168
180
  case "release":
169
181
  return await mutate(store, action, me, now, args, { kind: "release" });
170
182
  case "reclaim":
@@ -239,8 +251,8 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
239
251
  text.setText(collapsedLine(theme, outcome.details));
240
252
  return text;
241
253
  }
242
- const view =
243
- context?.lastComponent instanceof BoardResult ? context.lastComponent : new BoardResult(input, theme);
254
+ const previous = context?.lastComponent;
255
+ const view = isTailResult<Card>(previous) ? previous : new TailResult<Card>(input, theme);
244
256
  view.setInput(input, theme);
245
257
  return view;
246
258
  },
@@ -23,6 +23,7 @@ import {
23
23
  type ServeResponse,
24
24
  } from "../../../lib/http/index.ts";
25
25
  import type { BoardView } from "../../../lib/board/view.ts";
26
+ import { PING_PATH, SNAPSHOT_PATH } from "../../../lib/board/page-pointer.ts";
26
27
  import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
27
28
  import type { PageView } from "../view/page.ts";
28
29
 
@@ -45,13 +46,10 @@ export interface BoardRouterContext {
45
46
  page: (view: PageView) => Promise<string>;
46
47
  /** The client script. */
47
48
  script: string;
48
- /** The snapshot the page renders, and the body of `/api/board`. */
49
+ /** The snapshot the page renders, and the body of the snapshot route. */
49
50
  view: () => Promise<BoardView>;
50
51
  }
51
52
 
52
- /** The cheap liveness route: a probe reads this, and never the snapshot. */
53
- export const PING_PATH = "/api/ping";
54
-
55
53
  /**
56
54
  * Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values.
57
55
  */
@@ -77,7 +75,7 @@ export async function handleBoardRequest(
77
75
  // must not pay for the snapshot, which is a store read and a roster read on the same
78
76
  // event loop the probe is racing.
79
77
  if (pathname === PING_PATH) return jsonResponse(200, { ok: true });
80
- if (pathname === "/api/board") return jsonResponse(200, await ctx.view());
78
+ if (pathname === SNAPSHOT_PATH) return jsonResponse(200, await ctx.view());
81
79
  if (pathname === "/" || pathname === "/index.html") {
82
80
  return { status: 200, headers: pageHeaders(), body: await ctx.page(viewOf(search.get("view"))) };
83
81
  }