@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
@@ -8,8 +8,8 @@
8
8
  * know — nobody has touched it in half an hour — not that its owner's process is
9
9
  * gone.
10
10
  *
11
- * The groups overlap on purpose. A `doing` card with no plan and no touch for an
12
- * hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
11
+ * The groups overlap on purpose. A `doing` card with no plan and no touch for half
12
+ * an hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
13
13
  * half the reason a lead should look at it.
14
14
  *
15
15
  * Pure: no I/O and no clock of its own.
@@ -20,28 +20,33 @@ import type { Card } from "./card.ts";
20
20
  /** How long a `doing` card may go untouched before the rollup calls it idle. */
21
21
  export const IDLE_MS = 30 * 60 * 1_000;
22
22
 
23
- /** One owner, the live cards it holds, and the ones it finished. */
24
- export interface RoundGroup {
23
+ /**
24
+ * One owner, the live cards it holds, and the ones it finished.
25
+ *
26
+ * `T` is what the caller handed in: the rollup never builds a card, so a snapshot whose
27
+ * cards carry derived facts hands them back on the same terms it received them.
28
+ */
29
+ export interface RoundGroup<T extends Card = Card> {
25
30
  label: string;
26
- cards: Card[];
31
+ cards: T[];
27
32
  /** This owner's finished cards: history, kept so a teammate who is done is not erased. */
28
- done: Card[];
33
+ done: T[];
29
34
  }
30
35
 
31
36
  /** The lead's rollup of one board. */
32
- export interface RoundSummary {
37
+ export interface RoundSummary<T extends Card = Card> {
33
38
  /** Every owner with at least one unfinished card, ordered by label. */
34
- owners: RoundGroup[];
39
+ owners: RoundGroup<T>[];
35
40
  /** `todo` cards: nobody has taken them yet. */
36
- unassigned: Card[];
41
+ unassigned: T[];
37
42
  /** `doing` cards with no plan recorded: work started before it was planned. */
38
- unplanned: Card[];
43
+ unplanned: T[];
39
44
  /** `blocked` cards, and the note that says why. */
40
- blocked: Card[];
45
+ blocked: T[];
41
46
  /** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
42
- idle: Card[];
47
+ idle: T[];
43
48
  /** Finished cards, oldest first, kept as history. */
44
- done: Card[];
49
+ done: T[];
45
50
  }
46
51
 
47
52
  /**
@@ -55,13 +60,13 @@ export interface RoundSummary {
55
60
  * whose labels differ only by case are one owner — a name is case-insensitive when
56
61
  * it is a send target, so it is here too — and the first spelling seen is kept.
57
62
  */
58
- export function buildRound(cards: readonly Card[], now: number): RoundSummary {
59
- const groups = new Map<string, RoundGroup>();
60
- const unassigned: Card[] = [];
61
- const unplanned: Card[] = [];
62
- const blocked: Card[] = [];
63
- const idle: Card[] = [];
64
- const done: Card[] = [];
63
+ export function buildRound<T extends Card = Card>(cards: readonly T[], now: number): RoundSummary<T> {
64
+ const groups = new Map<string, RoundGroup<T>>();
65
+ const unassigned: T[] = [];
66
+ const unplanned: T[] = [];
67
+ const blocked: T[] = [];
68
+ const idle: T[] = [];
69
+ const done: T[] = [];
65
70
 
66
71
  for (const card of cards) {
67
72
  if (card.status === "todo") unassigned.push(card);
@@ -31,7 +31,9 @@ import {
31
31
  cardRef,
32
32
  DEFAULT_PRIORITY,
33
33
  dependsOf,
34
+ MAX_LOG,
34
35
  MAX_NOTES,
36
+ normalizeBlockedBy,
35
37
  normalizeNote,
36
38
  normalizePathHints,
37
39
  normalizePlanPath,
@@ -57,7 +59,13 @@ export type CardOp =
57
59
  */
58
60
  | { kind: "assign"; to: Actor; note?: unknown }
59
61
  | { kind: "release" }
60
- | { kind: "move"; status: Exclude<CardStatus, "todo"> }
62
+ /**
63
+ * Change lane. `blockedBy` says what a block waits on, and `landed` says which commit
64
+ * closed a card: both are read only by the lane they belong to, and both are optional,
65
+ * because a card may be blocked with nothing recorded and a `done` card may predate the
66
+ * field or live outside a repository.
67
+ */
68
+ | { kind: "move"; status: Exclude<CardStatus, "todo">; blockedBy?: unknown; landed?: { commit: string } }
61
69
  // `text` and `path` are whatever the model sent: `card.ts` validates them, and the
62
70
  // refusal it produces is the one the caller shows.
63
71
  | { kind: "note"; text: unknown }
@@ -66,6 +74,16 @@ export type CardOp =
66
74
  // sends the three it can show, and omitting a link must not clear it.
67
75
  | { kind: "edit"; title: unknown; paths: unknown; priority: unknown; dependsOn: unknown }
68
76
  | { kind: "reclaim" }
77
+ /**
78
+ * Record that the card was offered to the team: `team offer` broadcasts it on the bus
79
+ * and then writes this, because an event nobody answers otherwise leaves no trace.
80
+ */
81
+ | { kind: "offer" }
82
+ /**
83
+ * Set a finished card aside: the store moves its file out of the board, and the record
84
+ * itself is unchanged, because the pile its file lies in is the whole of the state.
85
+ */
86
+ | { kind: "archive" }
69
87
  | { kind: "drop" };
70
88
 
71
89
  /** What an operation did: the new card, or the reason it was refused. */
@@ -89,6 +107,10 @@ export function createCard(
89
107
  priority: input.priority ?? DEFAULT_PRIORITY,
90
108
  notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
91
109
  notesDropped: 0,
110
+ // A card is born in `todo`, and that is a transition like any other: a reader asking
111
+ // "how long has this been waiting" gets an answer rather than an absence.
112
+ log: [{ status: "todo", at: now, by: actor.label }],
113
+ logDropped: 0,
92
114
  rev: 1,
93
115
  createdAt: now,
94
116
  updatedAt: now,
@@ -125,7 +147,7 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
125
147
  case "release":
126
148
  return release(card, actor, now);
127
149
  case "move":
128
- return move(card, op.status, actor, now, board);
150
+ return move(card, op, actor, now, board);
129
151
  case "note":
130
152
  return note(card, op.text, actor, now);
131
153
  case "plan":
@@ -134,6 +156,10 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
134
156
  return edit(card, op, actor, now, board);
135
157
  case "reclaim":
136
158
  return reclaim(card, actor, now);
159
+ case "offer":
160
+ return offer(card, actor, now, board);
161
+ case "archive":
162
+ return archive(card);
137
163
  case "drop":
138
164
  return drop(card, actor, board);
139
165
  }
@@ -145,7 +171,11 @@ function claim(card: Card, actor: Actor, now: number, board: BoardIndex): OpResu
145
171
  if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
146
172
  const waiting = waitingOn(card, board);
147
173
  if (waiting !== undefined) return { ok: false, error: waiting };
148
- return { ok: true, card: write(card, actor, now, { status: "doing", owner: ownerOf(actor) }), changed: true };
174
+ return {
175
+ ok: true,
176
+ card: write(card, actor, now, { status: "doing", owner: ownerOf(actor), offeredAt: undefined }),
177
+ changed: true,
178
+ };
149
179
  }
150
180
 
151
181
  function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: number, board: BoardIndex): OpResult {
@@ -154,7 +184,7 @@ function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: numb
154
184
  if (holds(card, to) && card.status === "doing") return { ok: true, card, changed: false };
155
185
  const waiting = waitingOn(card, board);
156
186
  if (waiting !== undefined) return { ok: false, error: waiting };
157
- const fields: Partial<Card> = { status: "doing", owner: ownerOf(to) };
187
+ const fields: Partial<Card> = { status: "doing", owner: ownerOf(to), offeredAt: undefined };
158
188
  if (rawNote !== undefined) {
159
189
  const parsed = normalizeNote(rawNote);
160
190
  if ("error" in parsed) return { ok: false, error: parsed.error };
@@ -172,23 +202,66 @@ function release(card: Card, actor: Actor, now: number): OpResult {
172
202
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be released.` };
173
203
  if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} is not claimed.` };
174
204
  if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Release is the owner's decision.` };
175
- return { ok: true, card: write(card, actor, now, { status: "todo", owner: undefined }), changed: true };
205
+ return {
206
+ ok: true,
207
+ card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
208
+ changed: true,
209
+ };
176
210
  }
177
211
 
178
- function move(card: Card, status: Exclude<CardStatus, "todo">, actor: Actor, now: number, board: BoardIndex): OpResult {
212
+ function move(
213
+ card: Card,
214
+ op: Extract<CardOp, { kind: "move" }>,
215
+ actor: Actor,
216
+ now: number,
217
+ board: BoardIndex,
218
+ ): OpResult {
219
+ const status = op.status;
179
220
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be moved.` };
180
221
  if (card.owner === undefined) {
181
222
  return { ok: false, error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${status}.` };
182
223
  }
183
224
  if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner can move it.` };
184
- if (card.status === status) return { ok: true, card, changed: false };
225
+ // A block's reason is checked before the no-op: a caller that repeats a move with a new
226
+ // sentence meant to change it, and a refusal is clearer than a silent nothing.
227
+ let blockedBy: string | undefined;
228
+ if (status === "blocked" && op.blockedBy !== undefined) {
229
+ const parsed = normalizeBlockedBy(op.blockedBy);
230
+ if ("error" in parsed) return { ok: false, error: parsed.error };
231
+ blockedBy = parsed.blockedBy;
232
+ }
233
+ if (card.status === status && blockedBy === undefined) return { ok: true, card, changed: false };
185
234
  // Parking is what a link is for, so only the two lanes that put a card back in flight
186
235
  // are gated: a card that waits may be blocked, it may not be taken or finished.
187
236
  if (status !== "blocked") {
188
237
  const waiting = waitingOn(card, board);
189
238
  if (waiting !== undefined) return { ok: false, error: waiting };
190
239
  }
191
- return { ok: true, card: write(card, actor, now, { status }), changed: true };
240
+ return { ok: true, card: write(card, actor, now, laneFields(status, blockedBy, op, now)), changed: true };
241
+ }
242
+
243
+ /**
244
+ * The fields one lane change writes.
245
+ *
246
+ * `blockedBy` belongs to `blocked` alone: a card that leaves for `doing` or `done` has
247
+ * had its decision or stopped needing one, and a stale sentence would be read as a
248
+ * current one. `landed` belongs to `done` alone, and only when the caller could name a
249
+ * commit — a board outside git still finishes cards, it just cannot say with what.
250
+ */
251
+ function laneFields(
252
+ status: Exclude<CardStatus, "todo">,
253
+ blockedBy: string | undefined,
254
+ op: Extract<CardOp, { kind: "move" }>,
255
+ now: number,
256
+ ): Partial<Card> {
257
+ const fields: Partial<Card> = { status };
258
+ if (status === "blocked") {
259
+ if (blockedBy !== undefined) fields.blockedBy = blockedBy;
260
+ } else {
261
+ fields.blockedBy = undefined;
262
+ }
263
+ if (status === "done" && op.landed !== undefined) fields.landed = { commit: op.landed.commit, at: now };
264
+ return fields;
192
265
  }
193
266
 
194
267
  function note(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
@@ -207,6 +280,16 @@ function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<C
207
280
  };
208
281
  }
209
282
 
283
+ /** A card's log after one more transition, with the oldest dropped and counted. */
284
+ function appendTransition(card: Card, status: CardStatus, actor: Actor, now: number): Pick<Card, "log" | "logDropped"> {
285
+ const log = [...(card.log ?? []), { status, at: now, by: actor.label }];
286
+ const overflow = Math.max(0, log.length - MAX_LOG);
287
+ return {
288
+ log: overflow === 0 ? log : log.slice(overflow),
289
+ logDropped: (card.logDropped ?? 0) + overflow,
290
+ };
291
+ }
292
+
210
293
  function plan(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
211
294
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; its plan cannot change.` };
212
295
  if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner records a plan.` };
@@ -262,10 +345,45 @@ function edit(
262
345
  return { ok: true, card: write(card, actor, now, fields), changed: true };
263
346
  }
264
347
 
348
+ /**
349
+ * Record that the card was offered to the team, and nobody has taken it.
350
+ *
351
+ * The same preconditions a claim would meet, because the offer asks a peer to claim: a
352
+ * held card, a finished one, or one waiting on unfinished work is refused before anything
353
+ * is broadcast. Re-offering refreshes the age rather than refusing as a no-op — a lead
354
+ * who offers again is asking again, and the age is the record of when they last did.
355
+ */
356
+ function offer(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
357
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be offered.` };
358
+ if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
359
+ const waiting = waitingOn(card, board);
360
+ if (waiting !== undefined) return { ok: false, error: waiting };
361
+ return { ok: true, card: write(card, actor, now, { offeredAt: now }), changed: true };
362
+ }
363
+
265
364
  function reclaim(card: Card, actor: Actor, now: number): OpResult {
266
365
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
267
366
  if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} has no owner to reclaim.` };
268
- return { ok: true, card: write(card, actor, now, { status: "todo", owner: undefined }), changed: true };
367
+ return {
368
+ ok: true,
369
+ card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
370
+ changed: true,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Set a finished card aside, out of the board and into the archive beside it.
376
+ *
377
+ * Only a finished card leaves this way: `done` is terminal, and a card still in flight
378
+ * has an owner, a plan, and a decision in it. No field moves and no transition is
379
+ * appended — the record is unchanged, and what changed is which pile its file lies in,
380
+ * which is the store's write. `changed` is still true, because the board lost a card.
381
+ */
382
+ function archive(card: Card): OpResult {
383
+ if (card.status !== "done") {
384
+ return { ok: false, error: `${cardRef(card.id)} is in ${card.status}; only a finished card can be archived.` };
385
+ }
386
+ return { ok: true, card, changed: true };
269
387
  }
270
388
 
271
389
  function drop(card: Card, actor: Actor, board: BoardIndex): OpResult {
@@ -283,13 +401,28 @@ function sameList(left: readonly string[], right: readonly string[]): boolean {
283
401
  return left.length === right.length && left.every((entry, index) => entry === right[index]);
284
402
  }
285
403
 
286
- /** A card with `fields` applied and its write marks moved. */
404
+ /**
405
+ * A card with `fields` applied and its write marks moved.
406
+ *
407
+ * This is the one place a lane change is observed, because it is the one place every
408
+ * transition ends: a note, a plan, or an edit writes fields and leaves the lane alone,
409
+ * while a claim, an assign, a release, a reclaim, and a move all name the new one. So
410
+ * the log is appended from here rather than from each operation, and an operation added
411
+ * later cannot forget it.
412
+ */
287
413
  function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
288
- const next: Card = { ...card, ...fields, rev: card.rev + 1, updatedAt: now, by: actor.label };
414
+ const status = fields.status;
415
+ const transition =
416
+ status !== undefined && status !== card.status ? appendTransition(card, status, actor, now) : undefined;
417
+ const next: Card = { ...card, ...fields, ...transition, rev: card.rev + 1, updatedAt: now, by: actor.label };
289
418
  // `owner: undefined` must clear the field rather than store a key holding it, so a
290
419
  // released card reads back exactly like one that was never claimed. Only an
291
420
  // explicit `owner` in `fields` may delete it: every other write keeps the holder.
292
421
  if ("owner" in fields && fields.owner === undefined) delete next.owner;
422
+ // The same for the block: a card that left `blocked` has no reason, and a key holding
423
+ // undefined would be one more shape for a reader to know about.
424
+ if ("blockedBy" in fields && fields.blockedBy === undefined) delete next.blockedBy;
425
+ if ("offeredAt" in fields && fields.offeredAt === undefined) delete next.offeredAt;
293
426
  return next;
294
427
  }
295
428
 
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * One store per working directory, opened on first use and kept for the session:
5
5
  * a tool call is frequent and the repository lookup shells out to git, so the
6
- * answer is remembered per `cwd`. The identity is the same pair a card records —
7
- * the session id Pi knows, and the label `session-bus` would settle by default —
8
- * which is why the board can name its owner without reading the bus.
6
+ * answer is remembered per `cwd`. The identity is the same pair a card records — the
7
+ * session id Pi knows, and the team name derived from it — which is why the board can
8
+ * name its owner without reading the bus, and why the name survives a rename.
9
+ *
10
+ * One session per *host*, not one per extension: Pi loads `board` and `team` with
11
+ * separate module caches, so two sessions would mean the seat `/board` sets is
12
+ * invisible to every write `team` makes, and one session would name its own cards
13
+ * two ways.
9
14
  *
10
15
  * The store and its cache live in this closure, not in a module variable: Pi loads
11
16
  * each extension with an isolated module cache, so module state would be one copy
@@ -15,16 +20,39 @@
15
20
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
22
  import { basename, join } from "node:path";
18
- import { defaultLabel, sessionNameOf } from "../session-label.ts";
23
+ import { agentLabel, defaultLabel, sessionNameOf } from "../session-label.ts";
19
24
  import { BUS_DIR_NAME } from "../session-roster.ts";
25
+ import type { JoinResult } from "../session-tool.ts";
20
26
  import type { Actor } from "./rules.ts";
21
27
  import { type BoardStore, openBoard } from "./store.ts";
22
28
 
29
+ /**
30
+ * Where this session stands on the team, as the one place that keeps it.
31
+ *
32
+ * Two surfaces ask: `board` when it joins on the page or on a claim, and `team` for
33
+ * `/team join`. It is a standing rather than a flag because both need to *say* what
34
+ * happened — `/board status` names the label and whether mail is listened for — and
35
+ * because a failure has to be asked again while a success must not be. A failure is
36
+ * kept so a line can say why, and `ok` is what a caller reads to decide.
37
+ */
38
+ export interface TeamStanding {
39
+ /** What the last join did, or undefined when this session has not asked. */
40
+ last(): JoinResult | undefined;
41
+ /** Record what an attempt did; a failed one is kept, and asked again. */
42
+ record(result: JoinResult): void;
43
+ }
44
+
23
45
  /** What a tool, a page, and a round need from the session, without any of them owning it. */
24
46
  export interface BoardSession {
25
47
  /** The board for `cwd`'s repository. */
26
48
  store(cwd: string): Promise<BoardStore>;
27
- /** The session a write is made as. */
49
+ /**
50
+ * The session a write is made as: its team name, and the id a card keeps.
51
+ *
52
+ * The label is the seat while this session serves the page, else `team-agent-<id>`,
53
+ * else — on a host that reports no id — the readable chain. One name on every
54
+ * surface is the point: the card, the bus record, and the roster say the same word.
55
+ */
28
56
  who(ctx: ExtensionContext, cwd: string): Actor;
29
57
  /** The clock, so a test drives a deadline instead of waiting on one. */
30
58
  now(): number;
@@ -36,12 +64,23 @@ export interface BoardSession {
36
64
  * with `session-bus` unloaded.
37
65
  */
38
66
  busDir(cwd: string): Promise<string>;
39
- /** Whether this session has already joined the team, so a card write joins once. */
40
- joined(): boolean;
41
- /** Record that the join landed; a failed one leaves this false so the next write asks. */
42
- markJoined(): void;
67
+ /** The seat this session serves the page as, or undefined when it is not serving. */
68
+ serving(): string | undefined;
69
+ /** Serve the page under `seat`, or stand it down when the page stops. */
70
+ serve(seat: string | undefined): void;
71
+ /** Where this session stands on the team, so a claim joins once and status can say. */
72
+ team: TeamStanding;
43
73
  }
44
74
 
75
+ /**
76
+ * The part of a session a command needs: what it is called, and where it stands.
77
+ *
78
+ * `/board` joins, sets the seat, and reports the standing; it never opens a store or
79
+ * reads the bus, so a command takes this much and no more. A test drives it with four
80
+ * stand-ins instead of a whole session with a temp agent directory.
81
+ */
82
+ export type BoardTeam = Pick<BoardSession, "who" | "serving" | "serve" | "team">;
83
+
45
84
  /** Test seams and the one place a caller may redirect the store. */
46
85
  export interface BoardSessionOptions {
47
86
  now?: () => number;
@@ -49,16 +88,32 @@ export interface BoardSessionOptions {
49
88
  }
50
89
 
51
90
  /**
52
- * Build the session around one extension's `pi`.
91
+ * The key one host's session is kept under.
53
92
  *
54
- * Shared by `board` and `team`: both work on the same store and must name a write
55
- * as the same session, and two copies are how a card's owner label drifts from the
56
- * label the board writes.
93
+ * `Symbol.for` is the global registry, so the copy of this module in `board`'s cache
94
+ * and the copy in `team`'s agree on the key — a plain `Symbol()` would be two keys on
95
+ * one host, which is the drift this reuse exists to stop.
96
+ */
97
+ const SESSION_KEY = Symbol.for("@gavin-hu/my-pi-agent/board-session");
98
+
99
+ /** A host with this module's key on it. */
100
+ type BoardHost = ExtensionAPI & { [SESSION_KEY]?: BoardSession };
101
+
102
+ /**
103
+ * The session around one host, built on the first call and shared after it.
104
+ *
105
+ * `board` and `team` both work on the same store and must name a write as the same
106
+ * session, so the first caller's options are the ones built: a second extension joins
107
+ * the session that exists rather than opening a second one beside it.
57
108
  */
58
109
  export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions = {}): BoardSession {
110
+ const host = pi as BoardHost;
111
+ const shared = host[SESSION_KEY];
112
+ if (shared !== undefined) return shared;
59
113
  const stores = new Map<string, Promise<BoardStore>>();
60
114
  const busDirs = new Map<string, Promise<string>>();
61
- let teamJoined = false;
115
+ let seat: string | undefined;
116
+ let standing: JoinResult | undefined;
62
117
  const agentDir = options.agentDir;
63
118
  const session: BoardSession = {
64
119
  store(cwd) {
@@ -70,7 +125,11 @@ export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions
70
125
  },
71
126
  who(ctx, cwd) {
72
127
  const id = sessionIdOf(ctx);
73
- const label = defaultLabel(sessionNameOf(ctx), cwd, id ?? "session");
128
+ // The seat is a role and the agent name is derived from the id, so neither moves
129
+ // when a user renames the session — which is what keeps a card's owner readable
130
+ // against the roster a year later. A host that reports no id has nothing to derive
131
+ // from, so it keeps the bus's own readable chain.
132
+ const label = seat ?? (id === undefined ? defaultLabel(sessionNameOf(ctx), cwd) : agentLabel(id));
74
133
  return id === undefined ? { label } : { id, label };
75
134
  },
76
135
  now: options.now ?? Date.now,
@@ -86,11 +145,18 @@ export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions
86
145
  busDirs.set(cwd, resolved);
87
146
  return resolved;
88
147
  },
89
- joined: () => teamJoined,
90
- markJoined: () => {
91
- teamJoined = true;
148
+ serving: () => seat,
149
+ serve: (label) => {
150
+ seat = label;
151
+ },
152
+ team: {
153
+ last: () => standing,
154
+ record: (result) => {
155
+ standing = result;
156
+ },
92
157
  },
93
158
  };
159
+ host[SESSION_KEY] = session;
94
160
  return session;
95
161
  }
96
162