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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -47,7 +47,13 @@ export interface BusStore {
47
47
  releaseAll(owner: string): Promise<string[]>;
48
48
  prune(input: {
49
49
  now: number;
50
+ /** The sessions known to be live when the caller read the bus. */
50
51
  activeIds: ReadonlySet<string>;
52
+ /**
53
+ * Records whose process the caller found gone: they keep their row until their
54
+ * heartbeat ages out, and own no path and no mail in the meantime.
55
+ */
56
+ goneIds?: ReadonlySet<string>;
51
57
  sessionTtlMs: number;
52
58
  messageTtlMs: number;
53
59
  }): Promise<void>;
@@ -85,6 +91,7 @@ function isSession(value: unknown): value is SessionRecord {
85
91
  typeof entry.mode === "string" &&
86
92
  typeof entry.startedAt === "number" &&
87
93
  typeof entry.lastSeen === "number" &&
94
+ (entry.startToken === undefined || typeof entry.startToken === "string") &&
88
95
  (entry.watching === undefined || isWatchingState(entry.watching))
89
96
  );
90
97
  }
@@ -335,16 +342,21 @@ export function createBusStore(options: BusStoreOptions): BusStore {
335
342
  return released;
336
343
  },
337
344
 
338
- async prune({ now: at, activeIds, sessionTtlMs, messageTtlMs }) {
345
+ async prune({ now: at, activeIds, goneIds, sessionTtlMs, messageTtlMs }) {
339
346
  // The caller's set can lag: a peer that joined and claimed since that read is
340
347
  // live on disk, and its claims are its own. This read is the answer, so the
341
- // set only adds to what it found rather than deciding who survives.
348
+ // set only adds to what it found rather than deciding who survives — except for
349
+ // a record whose process is gone, which the caller knows and the file cannot.
350
+ // That record keeps its row until the heartbeat ages out and owns nothing.
342
351
  const live = new Set(activeIds);
352
+ const gone = new Set(goneIds ?? []);
343
353
  for (const session of await readSessions()) {
344
354
  if (at - session.lastSeen > sessionTtlMs) {
345
355
  await rm(sessionFile(session.id), { force: true });
346
356
  await dropIfEmpty(inboxDir(session.id));
347
357
  live.delete(session.id);
358
+ } else if (gone.has(session.id)) {
359
+ live.delete(session.id);
348
360
  } else {
349
361
  live.add(session.id);
350
362
  }
@@ -96,7 +96,7 @@ function boardKey(board: BoardSnapshot | undefined): string {
96
96
  return [
97
97
  ...board.status.sessions.map(
98
98
  (session) =>
99
- `${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}`,
99
+ `${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}:${session.state}`,
100
100
  ),
101
101
  ...board.status.claims.map((claim) => `${claim.path}:${claim.owner}:${claim.ownerLabel}:${claim.at}`),
102
102
  board.status.watching ? `${board.status.watching.armed}:${board.status.watching.wake}` : "",
@@ -5,6 +5,7 @@
5
5
  * `store.ts` and the lifecycle in `runtime.ts`.
6
6
  */
7
7
 
8
+ import type { RosterState } from "../../lib/session-roster.ts";
8
9
  import type { MessageKind, SessionMode } from "./protocol.ts";
9
10
 
10
11
  /** One registered Pi session. */
@@ -12,6 +13,14 @@ export interface SessionRecord {
12
13
  id: string;
13
14
  label: string;
14
15
  pid: number;
16
+ /**
17
+ * That process's start time, when the host can name one.
18
+ *
19
+ * It is what tells the record's process apart from an unrelated one that has since
20
+ * been given the same pid, so `gone` does not have to wait for the heartbeat to age.
21
+ * Absent on a platform without `ps`, and on a record written before this existed.
22
+ */
23
+ startToken?: string;
15
24
  /** Working directory of the session; the worktree root when there is one. */
16
25
  cwd: string;
17
26
  /** Repository root the session belongs to, for relative claims. */
@@ -57,7 +66,7 @@ export interface SessionStatus {
57
66
  me: { id: string; label: string };
58
67
  /** False while this session has only read the bus: it joins by naming itself. */
59
68
  onBus: boolean;
60
- sessions: Array<SessionRecord & { unread: number }>;
69
+ sessions: Array<SessionRecord & { unread: number; state: RosterState }>;
61
70
  claims: ClaimRecord[];
62
71
  /** Present only when this session armed its inbox watch. */
63
72
  watching?: { armed: boolean; wake: boolean };
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
+ import { planModeRefusal } from "../../lib/plan-entry.ts";
10
11
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
11
12
  import { withRailsSuppressed } from "../../lib/rails.ts";
12
13
  import type { BackgroundRuntime } from "./background.ts";
@@ -20,7 +21,7 @@ async function openAgents(
20
21
  ctx: ExtensionCommandContext,
21
22
  ): Promise<void> {
22
23
  if (isPlanModeEnabled(ctx.sessionManager.getBranch())) {
23
- ctx.ui.notify("Plan mode is read-only. Approve the plan before managing background runs.", "warning");
24
+ ctx.ui.notify(planModeRefusal("managing background runs"), "warning");
24
25
  return;
25
26
  }
26
27
 
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * The Team tab is the same round with a mouse; a lead reading a TUI should not have to
5
5
  * open a browser to see what needs a decision, so the command prints the rollup the
6
- * tool returns and one line saying who is live. It reads the cards and the bus's records
7
- * and writes nothing: acting on the round is `team`'s tool (`assign`, `reclaim`) or
8
- * `board`'s.
6
+ * tool returns, the work a dead owner left behind, and one line saying who is live. It
7
+ * reads the cards and the bus's records and writes nothing: acting on the round is
8
+ * `team`'s tool (`assign`, `reclaim`) or `board`'s.
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
- import type { Card } from "../../lib/board/card.ts";
12
+ import { type Card, cardRef } from "../../lib/board/card.ts";
13
13
  import { buildRound } from "../../lib/board/round.ts";
14
14
  import { formatRound, plural } from "../../lib/board/round-text.ts";
15
15
  import type { BoardSession } from "../../lib/board/session.ts";
@@ -77,26 +77,58 @@ export function roundText(cards: readonly Card[], now: number, roster?: readonly
77
77
  const round = formatRound(cards, buildRound(cards, now)).split("\n");
78
78
  const shown = round.slice(0, MAX_ROUND_LINES);
79
79
  if (round.length > MAX_ROUND_LINES) shown.push(`… ${plural(round.length - MAX_ROUND_LINES, "line")} more`);
80
+ for (const line of goneLines(cards, roster, now)) shown.push(line);
80
81
  const who = presenceLine(roster, now);
81
82
  if (who !== undefined) shown.push(who);
82
83
  return shown.join("\n");
83
84
  }
84
85
 
85
86
  /**
86
- * `live: a, b · not listening: b · quiet: c`, or nothing when nobody is here.
87
+ * The work a dead owner left behind, as the line that says who takes it.
87
88
  *
88
- * Three facts, and the middle one is the one a lead can act on: a live session that is
89
+ * One line per gone owner that still holds cards, before the presence line: `gone: b` at
90
+ * the end of that line says who died, and this one says what to do about it. A card that
91
+ * waits on another is left to the board, whose rules would refuse the claim anyway.
92
+ */
93
+ function goneLines(cards: readonly Card[], roster: readonly RosterSession[] | undefined, now: number): string[] {
94
+ if (roster === undefined) return [];
95
+ const gone = new Map<string, string>();
96
+ for (const session of roster) {
97
+ if (rosterStateOf(session, now) === "gone") gone.set(session.label.toLowerCase(), session.label);
98
+ }
99
+ if (gone.size === 0) return [];
100
+ const byOwner = new Map<string, string[]>();
101
+ for (const card of cards) {
102
+ if (card.owner === undefined || card.status === "done") continue;
103
+ const label = gone.get(card.owner.label.toLowerCase());
104
+ if (label === undefined) continue;
105
+ byOwner.set(label, [...(byOwner.get(label) ?? []), cardRef(card.id)]);
106
+ }
107
+ return [...byOwner.entries()].map(([label, refs]) => `reclaim ${refs.join(", ")} — ${label} is not running`);
108
+ }
109
+
110
+ /**
111
+ * `live: a, b · not listening: b · quiet: c · gone: d`, or nothing when nobody is here.
112
+ *
113
+ * Four facts, and the middle one is the one a lead can act on: a live session that is
89
114
  * not listening reads mail at its next turn rather than being woken for it. It is only
90
115
  * named when its record says so — a session that never wrote the field is not accused of
91
- * ignoring anything.
116
+ * ignoring anything. `gone` is the loudest and comes last, because it is the one a lead
117
+ * answers by reclaiming rather than by waiting.
92
118
  */
93
119
  function presenceLine(roster: readonly RosterSession[] | undefined, now: number): string | undefined {
94
120
  if (roster === undefined || roster.length === 0) return undefined;
95
121
  const live: string[] = [];
96
122
  const quiet: string[] = [];
123
+ const dead: string[] = [];
97
124
  const silent: string[] = [];
98
125
  for (const session of roster) {
99
- if (rosterStateOf(session, now) !== "live") {
126
+ const state = rosterStateOf(session, now);
127
+ if (state === "gone") {
128
+ dead.push(session.label);
129
+ continue;
130
+ }
131
+ if (state !== "live") {
100
132
  quiet.push(session.label);
101
133
  continue;
102
134
  }
@@ -107,6 +139,7 @@ function presenceLine(roster: readonly RosterSession[] | undefined, now: number)
107
139
  live.length > 0 ? `live: ${live.join(", ")}` : "",
108
140
  silent.length > 0 ? `not listening: ${silent.join(", ")}` : "",
109
141
  quiet.length > 0 ? `quiet: ${quiet.join(", ")}` : "",
142
+ dead.length > 0 ? `gone: ${dead.join(", ")}` : "",
110
143
  ];
111
144
  return parts.filter((part) => part !== "").join(" · ");
112
145
  }
@@ -86,6 +86,20 @@ export function formatOfferFailure(card: Card, reason: string): string {
86
86
  return `${cardRef(card.id)} was not offered: ${reason}\n The card is unchanged.`;
87
87
  }
88
88
 
89
+ /**
90
+ * The broadcast landed and recording the offer then lost a race.
91
+ *
92
+ * The peers have an event for a card somebody else has since claimed, moved, or taken
93
+ * over, so the correction says the offer stands as a message and the card is what the
94
+ * board now says — not that nothing happened.
95
+ */
96
+ export function formatRacedOffer(card: Card, reason: string): string {
97
+ return (
98
+ `${cardRef(card.id)} was offered, but the card changed before the offer could be recorded: ${reason}\n` +
99
+ " The peers have the message; the card is as the board shows it now."
100
+ );
101
+ }
102
+
89
103
  /**
90
104
  * The send landed and the assignment then lost a race.
91
105
  *
@@ -4,7 +4,8 @@
4
4
  * `board` owns the cards and never touches the session bus; `team` owns the two
5
5
  * actions that need it. `assign` hands a card to a session that is already running
6
6
  * and sends it a handoff — dispatch cannot start a peer's turn, and the page and the
7
- * docs say so — and `reclaim` takes a card back when its owner has gone quiet. Its one
7
+ * docs say so — and `reclaim` takes a card back when its owner is gone or has gone quiet.
8
+ * Its one
8
9
  * command, `/team`, prints the round the board's Team tab draws, without a browser.
9
10
  *
10
11
  * Either extension works without the other: `board` is the work list any session
@@ -24,17 +24,26 @@ import { failedResult, type ToolFailure, textResult } from "../../lib/tool-resul
24
24
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
25
25
  import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
26
26
  import { plural } from "../../lib/board/round-text.ts";
27
+ import { strayField } from "../../lib/stray-fields.ts";
27
28
  import { type Dispatch, handoffText, notListening, offerText, ownerFor, peerFor, sessionDispatch } from "./dispatch.ts";
28
29
  import {
29
30
  formatHandoff,
30
31
  formatHandoffFailure,
31
32
  formatOffer,
32
33
  formatOfferFailure,
34
+ formatRacedOffer,
33
35
  formatRacedAssignment,
34
36
  formatReclaimed,
35
37
  } from "./format.ts";
36
38
  import { TeamParams } from "./schema.ts";
37
- import { normalizeTeamAction, TEAM_ACTIONS, TEAM_TOOL, type TeamAction, type TeamDetails } from "./types.ts";
39
+ import {
40
+ ACTION_FIELDS,
41
+ normalizeTeamAction,
42
+ TEAM_ACTIONS,
43
+ TEAM_TOOL,
44
+ type TeamAction,
45
+ type TeamDetails,
46
+ } from "./types.ts";
38
47
 
39
48
  /** The parameters as the schema validates them. */
40
49
  type TeamArgs = Static<typeof TeamParams>;
@@ -74,7 +83,8 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
74
83
  "Open the board with `/board` or work a card first, so this session and its peers are on the team.",
75
84
  "Offer a card nobody has taken; assign one you mean a particular peer to hold.",
76
85
  "When a result says `Not listening`, say so: the message waits for that peer's next turn.",
77
- "Reclaim a card whose owner has gone quiet rather than editing someone else's work.",
86
+ "Reclaim a card whose owner is gone or has gone quiet rather than editing someone else's work. A " +
87
+ "`gone:` owner's process is not running, so what it held is anyone's to reclaim.",
78
88
  ],
79
89
  parameters: TeamParams,
80
90
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
@@ -86,6 +96,10 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
86
96
  if (!action) {
87
97
  return refusal("assign", `unknown action "${String(args.action)}"; use ${TEAM_ACTIONS.join(" or ")}.`);
88
98
  }
99
+ // Before the store and before the card: a refused call has done nothing, so it
100
+ // cannot have read a card or sent a message on its way to the refusal.
101
+ const stray = strayField(action, args, ACTION_FIELDS);
102
+ if (stray !== undefined) return refusal(action, stray);
89
103
  const id = normalizeCardId(args.card);
90
104
  if (id === undefined) return refusal(action, `"${String(args.card)}" is not a card id.`);
91
105
  const me = session.who(ctx, ctx.cwd);
@@ -93,7 +107,7 @@ export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: Tea
93
107
  const store = await session.store(ctx.cwd);
94
108
  const now = session.now();
95
109
  if (action === "reclaim") return await reclaimCard(store, me, id, now);
96
- if (action === "offer") return await offerCard(store, id, args.note, now, dispatchFor(ctx));
110
+ if (action === "offer") return await offerCard(store, me, id, args.note, now, dispatchFor(ctx));
97
111
  const to = typeof args.to === "string" ? args.to.trim() : "";
98
112
  if (to === "") return refusal("assign", 'assign needs a peer: pass `to`, e.g. "billing-core".');
99
113
  return await assignCard(store, me, id, to, args.note, now, dispatchFor(ctx));
@@ -180,14 +194,17 @@ async function assignCard(
180
194
  const OFFER_CLAIMANT: Actor = { label: "*" };
181
195
 
182
196
  /**
183
- * `offer`: check the claim's preconditions, broadcast the card, and write nothing.
197
+ * `offer`: check the claim's preconditions, broadcast the card, then record the offer.
184
198
  *
185
- * The order matters less here than in `assign`, because nothing is written: a failed
186
- * broadcast leaves the card exactly as it was, and the first peer to claim is what makes
187
- * an offer into work. Two peers racing are settled by the store's compare-and-set.
199
+ * The broadcast comes first and the record second, because a record of an offer nobody
200
+ * received would be worse than an offer with no record: the card is unchanged by a failed
201
+ * send, and the first peer to claim is what makes an offer into work. What the record
202
+ * adds is the one thing an event cannot carry — when it was made — so a lead can tell an
203
+ * offer nobody took from a card nobody has looked at.
188
204
  */
189
205
  async function offerCard(
190
206
  store: BoardStore,
207
+ me: Actor,
191
208
  id: string,
192
209
  note: string | undefined,
193
210
  now: number,
@@ -206,11 +223,15 @@ async function offerCard(
206
223
  } catch (error) {
207
224
  return refusal("offer", formatOfferFailure(card, messageOf(error)), card);
208
225
  }
226
+ // The peers have the event, so a card that changed underneath is reported as raced
227
+ // rather than refused: the message is already in their inboxes.
228
+ const recorded = await store.mutate(id, { kind: "offer" }, me, now);
229
+ if (!recorded.ok) return refusal("offer", formatRacedOffer(recorded.card ?? card, recorded.error), recorded.card);
209
230
  const peers = await dispatch.peers().catch(() => undefined);
210
231
  const silent = notListening(peers, delivered);
211
- return textResult(formatOffer(card, delivered, silent), {
232
+ return textResult(formatOffer(recorded.card, delivered, silent), {
212
233
  action: "offer",
213
- card,
234
+ card: recorded.card,
214
235
  delivered,
215
236
  ...(silent.length === 0 ? {} : { notListening: silent }),
216
237
  });
@@ -42,6 +42,23 @@ export interface TeamDetails {
42
42
  error?: string;
43
43
  }
44
44
 
45
+ /**
46
+ * The fields each action acts on: what a call may carry, per action.
47
+ *
48
+ * The parameters are one flat object, so a field is published to every action at once,
49
+ * and only this table stops one reaching an action that would drop it. `to` is `assign`'s
50
+ * alone: an `offer` that named a peer was broadcast to every live session instead, and a
51
+ * `reclaim` that named one took the card back from whoever held it.
52
+ *
53
+ * A test holds the other direction — every published parameter but `action` is claimed
54
+ * here — so a field cannot be published without an action that acts on it.
55
+ */
56
+ export const ACTION_FIELDS: Record<TeamAction, readonly string[]> = {
57
+ assign: ["card", "to", "note"],
58
+ offer: ["card", "note"],
59
+ reclaim: ["card"],
60
+ };
61
+
45
62
  /** Whether a value is one of the actions, so an unknown one is a refusal and not a crash. */
46
63
  export function normalizeTeamAction(raw: unknown): TeamAction | undefined {
47
64
  return typeof raw === "string" && TEAM_ACTIONS.includes(raw as TeamAction) ? (raw as TeamAction) : undefined;
@@ -60,7 +60,10 @@ export default function todo(pi: ExtensionAPI) {
60
60
  pi.on("tool_execution_end", (event) => {
61
61
  if (event.isError) return;
62
62
  const annotations = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
63
- if (!isMutatingTool(event.toolName, annotations)) return;
63
+ // The result is the only place a call's own record of what it did survives to this
64
+ // event, which is why the board's read action is read from here.
65
+ const details = (event.result as { details?: unknown } | undefined)?.details;
66
+ if (!isMutatingTool(event.toolName, annotations, details)) return;
64
67
  runtime.noteWork(isFileWrite(event.toolName, annotations));
65
68
  });
66
69
 
@@ -14,7 +14,8 @@
14
14
  */
15
15
 
16
16
  import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
17
- import { TODO_TOOL } from "../../lib/tool-names.ts";
17
+ import { isBoardRead } from "../../lib/policy.ts";
18
+ import { BOARD_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
18
19
  import { currentTodo, hasOpenTodos } from "./state.ts";
19
20
  import type { Todo } from "./types.ts";
20
21
 
@@ -32,9 +33,14 @@ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit"
32
33
  * Whether a finished tool call counts as work for lag detection. The `todo`
33
34
  * tool never does (it is the update itself), a known mutator always does, and
34
35
  * any other tool counts only when it explicitly declares `readOnlyHint: false`.
36
+ *
37
+ * `board` is the one mutator whose read face is provable from what the call
38
+ * returned: a result whose `details` say `action: "list"` read the cards and changed
39
+ * nothing, so it counts like every other reader.
35
40
  */
36
- export function isMutatingTool(toolName: string, annotations?: ToolAnnotations): boolean {
41
+ export function isMutatingTool(toolName: string, annotations?: ToolAnnotations, details?: unknown): boolean {
37
42
  if (toolName === TODO_TOOL) return false;
43
+ if (toolName === BOARD_TOOL && isBoardRead(details)) return false;
38
44
  if (MUTATING_TOOL_NAMES.has(toolName)) return true;
39
45
  return annotations?.readOnlyHint === false;
40
46
  }