paseo-bm-plugin 0.0.0-placeholder.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
@@ -0,0 +1,365 @@
1
+ import type { PluginLifecycleEvents, PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { PARENT_AGENT_LABEL } from "./manager";
3
+ import { REVIEWER_STOP_NOTICE_PREFIX, WORKER_STOP_NOTICE } from "./notices";
4
+ import { listAllAgents, roleOfAgent, roleOfProvider } from "./agent-role";
5
+
6
+ /**
7
+ * Stop propagation from a Beads Worker to its running Reviewers (bm-wq6, REQ-026f).
8
+ *
9
+ * When the user stops a Worker (app Stop button or `paseo stop`), Paseo 0.8
10
+ * interrupts only the Worker's turn; a Reviewer the Worker created keeps
11
+ * running. Paseo gives plugins no agent cancel, so the closest supported
12
+ * interrupt is used: `PaseoAgentHandle.send()` on a running agent replaces its
13
+ * current turn with a new one carrying a fixed stop notice, which
14
+ * `roles/reviewer.md` answers with one line and no tool calls.
15
+ *
16
+ * SDK facts this relies on (checked against @getpaseo/plugin 0.8.0 and
17
+ * @getpaseo/client 0.8.0 typings, not guessed):
18
+ * - `on("agent.turn_ended", (event, { paseo, signal }) => …)`; the event carries
19
+ * `agent: { id, workspaceId, provider, … }` and `outcome`, whose `kind` is
20
+ * `completed`, `failed` or `canceled` (lifecycle.d.ts).
21
+ * - `canceled` does not tell a user stop from a message that replaced the
22
+ * turn, so the Worker is re-read with `agents.ref(id).refresh()`: `running`
23
+ * means a new turn already started and nothing is done.
24
+ * - `agents.list({ filter: { labels, includeArchived }, page })` pages like
25
+ * `manager.ts`; the result is filtered again here, never trusted.
26
+ *
27
+ * Lifecycle belongs to the user (ADR-005): nothing here archives or deletes.
28
+ */
29
+
30
+ /** `bm.role` value that identifies a Reviewer (design §5). */
31
+ export const REVIEWER_ROLE_VALUE = "reviewer";
32
+
33
+ /**
34
+ * Wait before the single re-check of a Worker that still reads `running` right
35
+ * after its turn was canceled. Mirrors the plugin poll interval of design §8
36
+ * (`PLUGIN_POLL_INTERVAL_MS` in the CLI, which the plugin bundle cannot import);
37
+ * it is not a new product timeout.
38
+ */
39
+ export const STOP_RECHECK_MS = 500;
40
+
41
+ /**
42
+ * The notice sent to each running Reviewer of a stopped Worker. `roles/reviewer.md`
43
+ * recognises it verbatim; do not reword it without changing the role.
44
+ */
45
+ export const REVIEWER_STOP_NOTICE =
46
+ `${REVIEWER_STOP_NOTICE_PREFIX} by the user. Stop this review now: do not read files, run commands or call any tool; reply with the single line "BM-REVIEW STOPPED" and end your turn.`;
47
+
48
+ const LOG_PREFIX = "[paseo-bm] stop propagation:";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Minimal, injectable view of the Paseo SDK. `PaseoApi` from @getpaseo/client
52
+ // is structurally assignable to it (registerStopPropagation passes the real
53
+ // one, which `npm run typecheck:plugin` checks); tests pass a fake.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface StopAgentSnapshot {
57
+ id: string;
58
+ workspaceId?: string;
59
+ status: string;
60
+ labels: Record<string, string>;
61
+ /** Provider selection; decides the role when the `bm.role` label is missing (delta 20260918g). */
62
+ provider?: string;
63
+ archivedAt?: string | null;
64
+ }
65
+
66
+ export interface StopAgentHandle {
67
+ refresh(): Promise<{ agent: StopAgentSnapshot } | null>;
68
+ send(text: string): Promise<void>;
69
+ }
70
+
71
+ export interface StopPaseo {
72
+ agents: {
73
+ list(options: {
74
+ filter: { labels?: Record<string, string>; includeArchived: boolean };
75
+ page: { limit: number; cursor?: string };
76
+ }): Promise<{
77
+ entries: Array<{ agent: StopAgentSnapshot }>;
78
+ pageInfo: { nextCursor: string | null; hasMore: boolean };
79
+ }>;
80
+ ref(agentId: string): StopAgentHandle;
81
+ };
82
+ }
83
+
84
+ export type TurnEndedEvent = PluginLifecycleEvents["agent.turn_ended"];
85
+
86
+ export interface StopPropagationContext {
87
+ paseo: StopPaseo;
88
+ signal?: AbortSignal;
89
+ }
90
+
91
+ function describeError(error: unknown): string {
92
+ return error instanceof Error ? error.message : String(error);
93
+ }
94
+
95
+ function isAborted(signal: AbortSignal | undefined): boolean {
96
+ return signal?.aborted === true;
97
+ }
98
+
99
+ /** Resolves after `ms`, or at once when `signal` aborts. Never rejects. */
100
+ function wait(ms: number, signal: AbortSignal | undefined): Promise<void> {
101
+ return new Promise((resolve) => {
102
+ if (isAborted(signal)) {
103
+ resolve();
104
+ return;
105
+ }
106
+ const done = () => {
107
+ clearTimeout(timer);
108
+ signal?.removeEventListener("abort", done);
109
+ resolve();
110
+ };
111
+ const timer = setTimeout(done, ms);
112
+ signal?.addEventListener("abort", done, { once: true });
113
+ });
114
+ }
115
+
116
+ /** The event is the end of a canceled turn of a Worker (`bm-worker`, or a fallback Worker alias). */
117
+ export function isCanceledWorkerTurn(event: TurnEndedEvent): boolean {
118
+ const candidate = event as Partial<TurnEndedEvent> | null | undefined;
119
+ return (
120
+ candidate?.outcome?.kind === "canceled" &&
121
+ typeof candidate.agent?.id === "string" &&
122
+ roleOfProvider(candidate.agent.provider) === "worker"
123
+ );
124
+ }
125
+
126
+ /** A live, running Reviewer created by `workerId` in `workspaceId` (when known). */
127
+ function isRunningReviewerOf(
128
+ agent: StopAgentSnapshot | null | undefined,
129
+ workerId: string,
130
+ workspaceId: string | null,
131
+ ): agent is StopAgentSnapshot {
132
+ if (!agent) return false;
133
+ const labels = agent.labels ?? {};
134
+ return (
135
+ labels[PARENT_AGENT_LABEL] === workerId &&
136
+ // By label, or by the bm-reviewer provider when the label is missing (delta 20260918g §4.2).
137
+ roleOfAgent(agent)?.role === REVIEWER_ROLE_VALUE &&
138
+ !agent.archivedAt &&
139
+ agent.status === "running" &&
140
+ (workspaceId === null || agent.workspaceId === workspaceId)
141
+ );
142
+ }
143
+
144
+ async function readStatus(paseo: StopPaseo, agentId: string): Promise<StopAgentSnapshot | null> {
145
+ const result = await paseo.agents.ref(agentId).refresh();
146
+ return result?.agent ?? null;
147
+ }
148
+
149
+ /** Every running Reviewer of the Worker, walking all pages. */
150
+ async function listRunningReviewers(
151
+ paseo: StopPaseo,
152
+ workerId: string,
153
+ workspaceId: string | null,
154
+ ): Promise<StopAgentSnapshot[]> {
155
+ // Filtered on the parent only: a Reviewer without the bm.role label is still
156
+ // a Reviewer when its provider is bm-reviewer (delta 20260918g §4.2).
157
+ const children = await listAllAgents((options) => paseo.agents.list(options), {
158
+ labels: { [PARENT_AGENT_LABEL]: workerId },
159
+ includeArchived: false,
160
+ });
161
+ return children.filter((agent) => isRunningReviewerOf(agent, workerId, workspaceId));
162
+ }
163
+
164
+ /**
165
+ * Sends `REVIEWER_STOP_NOTICE` to every running Reviewer of `workerId` (in
166
+ * `workspaceId` when known), re-reading each one just before the send and
167
+ * skipping it unless it is still running. Returns the ids it sent to. Never
168
+ * throws: a failure costs one log line; an aborted `signal` ends it quietly.
169
+ * Also used when a fallback Worker replaces a stopped one (delta 20260921 §4.4.7).
170
+ */
171
+ export async function stopRunningReviewers(
172
+ paseo: StopPaseo,
173
+ workerId: string,
174
+ workspaceId: string | null,
175
+ signal?: AbortSignal,
176
+ ): Promise<string[]> {
177
+ const stopped: string[] = [];
178
+ let reviewers: StopAgentSnapshot[];
179
+ try {
180
+ reviewers = await listRunningReviewers(paseo, workerId, workspaceId);
181
+ } catch (error) {
182
+ if (!isAborted(signal)) {
183
+ console.warn(`${LOG_PREFIX} could not list the Reviewers of Worker ${workerId}: ${describeError(error)}`);
184
+ }
185
+ return stopped;
186
+ }
187
+ for (const reviewer of reviewers) {
188
+ if (isAborted(signal)) return stopped;
189
+ try {
190
+ const handle = paseo.agents.ref(reviewer.id);
191
+ const fresh = await handle.refresh();
192
+ if (isAborted(signal)) return stopped;
193
+ if (!isRunningReviewerOf(fresh?.agent, workerId, workspaceId)) continue;
194
+ await handle.send(REVIEWER_STOP_NOTICE);
195
+ stopped.push(reviewer.id);
196
+ } catch (error) {
197
+ if (isAborted(signal)) return stopped;
198
+ console.warn(`${LOG_PREFIX} could not stop Reviewer ${reviewer.id} of Worker ${workerId}: ${describeError(error)}`);
199
+ }
200
+ }
201
+ return stopped;
202
+ }
203
+
204
+ /**
205
+ * Handler body of `on("agent.turn_ended")`. Resolves in every case: failures are
206
+ * logged with `console.warn`, an aborted `signal` ends it quietly.
207
+ *
208
+ * 1. Ignore anything but a canceled turn of a `bm-worker`.
209
+ * 2. Re-read the Worker; if it is `running`, wait `STOP_RECHECK_MS` once and
210
+ * read again. Still `running` means a message replaced the turn: stop here.
211
+ * 3. List the Worker's running, non-archived `bm.role=reviewer` children in its
212
+ * workspace; re-read each one just before sending and skip it unless it is
213
+ * still running; send `REVIEWER_STOP_NOTICE`.
214
+ */
215
+ export async function propagateWorkerStop(
216
+ event: TurnEndedEvent,
217
+ context: StopPropagationContext,
218
+ ): Promise<void> {
219
+ const signal = context?.signal;
220
+ try {
221
+ if (!isCanceledWorkerTurn(event) || isAborted(signal)) return;
222
+ const { paseo } = context;
223
+ const workerId = event.agent.id;
224
+
225
+ let worker: StopAgentSnapshot | null;
226
+ try {
227
+ worker = await readStatus(paseo, workerId);
228
+ if (isAborted(signal)) return;
229
+ if (worker?.status === "running") {
230
+ await wait(STOP_RECHECK_MS, signal);
231
+ if (isAborted(signal)) return;
232
+ worker = await readStatus(paseo, workerId);
233
+ if (isAborted(signal)) return;
234
+ if (worker?.status === "running") return;
235
+ }
236
+ } catch (error) {
237
+ if (!isAborted(signal)) {
238
+ console.warn(`${LOG_PREFIX} could not re-read Worker ${workerId}: ${describeError(error)}`);
239
+ }
240
+ return;
241
+ }
242
+
243
+ const workspaceId = event.agent.workspaceId ?? worker?.workspaceId ?? null;
244
+ await stopRunningReviewers(paseo, workerId, workspaceId, signal);
245
+ } catch (error) {
246
+ if (!isAborted(signal)) {
247
+ console.warn(`${LOG_PREFIX} unexpected failure: ${describeError(error)}`);
248
+ }
249
+ }
250
+ }
251
+
252
+ /** The part of the server context this hook needs; `on` is absent on older hosts. */
253
+ export type StopPropagationHost = Partial<Pick<PluginServerContext, "on" | "before">>;
254
+
255
+ /**
256
+ * Registers the `on("agent.turn_ended")` hook and returns its remover. On a host
257
+ * without `on` it returns a no-op and logs one line — unless the host has no
258
+ * lifecycle hooks at all (no `before` either), where `registerRoleHook` has
259
+ * already logged the one line for that host.
260
+ */
261
+ export function registerStopPropagation(host: StopPropagationHost): () => void {
262
+ if (typeof host.on !== "function") {
263
+ if (typeof host.before === "function") {
264
+ console.warn(
265
+ "[paseo-bm] this Paseo host has no on(\"agent.turn_ended\") hook; stopping a Worker will not stop its running Reviewers.",
266
+ );
267
+ }
268
+ return () => {};
269
+ }
270
+ const remove = host.on("agent.turn_ended", (event, context) =>
271
+ propagateWorkerStop(event, { paseo: context?.paseo, signal: context?.signal }),
272
+ );
273
+ return typeof remove === "function" ? remove : () => {};
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // `/bm-worker-stop-all` (delta 20260917e §4.4).
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Label value of a Beads Worker. */
281
+ export const WORKER_ROLE_VALUE = "worker";
282
+
283
+ export interface StopAllResult {
284
+ /** Workers the notice reached. */
285
+ workers: number;
286
+ /** Reviewers the notice reached. */
287
+ reviewers: number;
288
+ /** Candidates that were archived, closed or no longer running when re-read. */
289
+ skipped: number;
290
+ }
291
+
292
+ /**
293
+ * Every non-archived agent of one role in one workspace, walking all pages.
294
+ *
295
+ * Listed with no label filter and decided by `roleOfAgent` (delta 20260918g
296
+ * §4.2): a Worker or Reviewer started without the bm.role label is found by its
297
+ * provider. The role is decided per agent here, never by the daemon's filter, so
298
+ * nothing this command never promised to touch — the Manager above all — can
299
+ * slip in: a Manager's role is `manager` by label and by provider alike.
300
+ */
301
+ async function listByRole(paseo: StopPaseo, role: string, workspaceId: string): Promise<StopAgentSnapshot[]> {
302
+ const all = await listAllAgents((options) => paseo.agents.list(options), { includeArchived: false });
303
+ return all.filter(
304
+ (agent) => agent && roleOfAgent(agent)?.role === role && !agent.archivedAt && agent.workspaceId === workspaceId,
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Asks every running Worker and Reviewer of one workspace to stop.
310
+ *
311
+ * **This asks; it does not force.** Paseo gives plugins no agent cancel, so the
312
+ * only supported interrupt is `send()` on a running agent, which replaces the
313
+ * turn it is in with one carrying a stop notice. Everything downstream — the
314
+ * command's reply to the user, the wording in the role files — has to say
315
+ * "asked to stop", never "stopped".
316
+ *
317
+ * Three rules the loop exists to keep:
318
+ * - **The Manager is never touched.** It is the user's point of contact, and the
319
+ * command's own name only promises Workers. Only the worker and reviewer
320
+ * roles are ever collected, by label or by provider.
321
+ * - **One workspace only** (owner decision Q28). A machine-wide stop is too
322
+ * large a consequence for one mistyped line.
323
+ * - **An archived agent is skipped**, because `send()` would UN-archive it, and
324
+ * ADR-005 says agents belong to the user. Each candidate is re-read
325
+ * immediately before the send, so an agent archived or finished since the
326
+ * listing is skipped too rather than resurrected.
327
+ */
328
+ export async function stopAllInWorkspace(
329
+ paseo: StopPaseo,
330
+ workspaceId: string,
331
+ log: (message: string) => void = (message) => console.warn(message),
332
+ ): Promise<StopAllResult> {
333
+ const result: StopAllResult = { workers: 0, reviewers: 0, skipped: 0 };
334
+ const targets: Array<{ agent: StopAgentSnapshot; role: string; notice: string }> = [
335
+ ...(await listByRole(paseo, WORKER_ROLE_VALUE, workspaceId)).map((agent) => ({
336
+ agent,
337
+ role: WORKER_ROLE_VALUE,
338
+ notice: WORKER_STOP_NOTICE,
339
+ })),
340
+ ...(await listByRole(paseo, REVIEWER_ROLE_VALUE, workspaceId)).map((agent) => ({
341
+ agent,
342
+ role: REVIEWER_ROLE_VALUE,
343
+ notice: REVIEWER_STOP_NOTICE,
344
+ })),
345
+ ];
346
+
347
+ for (const target of targets) {
348
+ // Re-read: the listing is a moment old, and `send()` on an agent archived
349
+ // in between would bring it back (ADR-005).
350
+ const fresh = await readStatus(paseo, target.agent.id);
351
+ if (!fresh || fresh.archivedAt || fresh.status !== "running") {
352
+ result.skipped += 1;
353
+ continue;
354
+ }
355
+ try {
356
+ await paseo.agents.ref(target.agent.id).send(target.notice);
357
+ if (target.role === WORKER_ROLE_VALUE) result.workers += 1;
358
+ else result.reviewers += 1;
359
+ } catch (error) {
360
+ result.skipped += 1;
361
+ log(`${LOG_PREFIX} could not ask ${target.agent.id} to stop (${error instanceof Error ? error.message : String(error)}).`);
362
+ }
363
+ }
364
+ return result;
365
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Whether a new Manager or Worker has Paseo's tools (delta 20260921 §4.2.4,
3
+ * REQ-063 d).
4
+ *
5
+ * Pi drops `mcpServers` silently when the `pi-mcp-adapter` extension is
6
+ * missing (proposal P4): the agent is created but has no Paseo tools, so a
7
+ * Worker cannot send a `BM-REPORT` or create a Reviewer. The agent's
8
+ * `capabilities.supportsMcpServers` says so. For a new Worker the plugin tells
9
+ * the parent Manager with a `BM-TOOLS` notice, through the notice queue, since
10
+ * that Manager is usually still running the turn that created the Worker
11
+ * (design F13). Every check is also kept in memory for the Setup screen.
12
+ *
13
+ * That `supportsMcpServers` is `false` for Pi without its adapter is inferred
14
+ * from the code (proposal O4), not observed: the owner checks it on the real
15
+ * daemon at the phase acceptance. Nothing here throws.
16
+ */
17
+ import { roleOfAgent } from "./agent-role";
18
+ import { TOOLS_NOTICE_MARKER } from "./notices";
19
+ import { enqueue as defaultEnqueue, type NoticeOutcome, type NoticePaseo } from "./notice-queue";
20
+
21
+ /** The two roles that need Paseo's tools. */
22
+ export type ToolsRole = "manager" | "worker";
23
+
24
+ export type ToolsState = "ok" | "missing" | "unknown";
25
+
26
+ /** The last check of one role, as `setup.status` reports it. */
27
+ export interface ToolsSeen {
28
+ state: ToolsState;
29
+ agentId: string;
30
+ provider: string;
31
+ at: string;
32
+ }
33
+
34
+ const seen = new Map<ToolsRole, ToolsSeen>();
35
+
36
+ /** The state a `supportsMcpServers` value means. */
37
+ export function toolsStateOf(supportsMcpServers: unknown): ToolsState {
38
+ return supportsMcpServers === true ? "ok" : supportsMcpServers === false ? "missing" : "unknown";
39
+ }
40
+
41
+ /** Records the check of one agent; the newest check of a role wins. */
42
+ export function recordTools(role: ToolsRole, agentId: string, provider: string, supportsMcpServers: unknown, at: string = new Date().toISOString()): ToolsSeen {
43
+ const entry: ToolsSeen = { state: toolsStateOf(supportsMcpServers), agentId, provider, at };
44
+ seen.set(role, entry);
45
+ return entry;
46
+ }
47
+
48
+ /** The last check of each role, or `null` when none ran in this plugin run. */
49
+ export function toolsSeen(): { manager: ToolsSeen | null; worker: ToolsSeen | null } {
50
+ return { manager: seen.get("manager") ?? null, worker: seen.get("worker") ?? null };
51
+ }
52
+
53
+ /** Forgets every check; for tests. */
54
+ export function forgetTools(): void {
55
+ seen.clear();
56
+ }
57
+
58
+ /** The notice the parent Manager gets. Agent-facing, so English. */
59
+ export function toolsNotice(workerId: string, provider: string): string {
60
+ return `${TOOLS_NOTICE_MARKER} Worker ${workerId} runs on ${provider} without Paseo tools (on Pi this means pi-mcp-adapter is missing). It cannot send you a BM-REPORT or create a Reviewer. Tell the user in one line; do not create another Worker for this request unless the user asks.`;
61
+ }
62
+
63
+ /** The snapshot fields this module reads. */
64
+ export interface ToolsAgentSnapshot {
65
+ status?: string | null;
66
+ archivedAt?: string | null;
67
+ provider?: string;
68
+ labels?: Record<string, string> | null;
69
+ capabilities?: { supportsMcpServers?: unknown } | null;
70
+ }
71
+
72
+ /** The SDK slice this module reads; `PaseoApi` is structurally assignable, and so is it to the notice queue's. */
73
+ export interface ToolsPaseo {
74
+ agents: {
75
+ ref(agentId: string): {
76
+ refresh(): Promise<{ agent?: ToolsAgentSnapshot | null } | null>;
77
+ send(text: string): Promise<void>;
78
+ };
79
+ };
80
+ }
81
+
82
+ export interface ToolsCheckDeps {
83
+ enqueue?: (targetId: string, kind: string, text: string, paseo?: NoticePaseo) => Promise<NoticeOutcome>;
84
+ log?: (message: string) => void;
85
+ }
86
+
87
+ /**
88
+ * Checks a Worker just created. `agent` is the `agent.created` event's agent.
89
+ * Records the result; when the Worker has no Paseo tools and its parent is a
90
+ * Manager, queues `BM-TOOLS` for that Manager. Never throws.
91
+ */
92
+ export async function checkWorkerTools(
93
+ agent: { id: string; provider: string; parentAgentId?: string | null },
94
+ paseo: ToolsPaseo,
95
+ deps: ToolsCheckDeps = {},
96
+ ): Promise<ToolsState> {
97
+ const log = deps.log ?? ((message: string) => console.warn(message));
98
+ try {
99
+ const snapshot = await paseo.agents.ref(agent.id).refresh();
100
+ const entry = recordTools("worker", agent.id, agent.provider, snapshot?.agent?.capabilities?.supportsMcpServers);
101
+ if (entry.state !== "missing") return entry.state;
102
+ const parentId = agent.parentAgentId ?? null;
103
+ if (parentId === null) {
104
+ log(`[paseo-bm] Worker ${agent.id} runs on ${agent.provider} without Paseo tools, and has no Manager to tell.`);
105
+ return entry.state;
106
+ }
107
+ const parent = await paseo.agents.ref(parentId).refresh();
108
+ if (roleOfAgent(parent?.agent)?.role !== "manager") {
109
+ log(`[paseo-bm] Worker ${agent.id} runs on ${agent.provider} without Paseo tools; its parent ${parentId} is not a Beads Manager, so nobody is told.`);
110
+ return entry.state;
111
+ }
112
+ await (deps.enqueue ?? defaultEnqueue)(parentId, TOOLS_NOTICE_MARKER, toolsNotice(agent.id, agent.provider), paseo);
113
+ return entry.state;
114
+ } catch (error) {
115
+ log(`[paseo-bm] checking the Paseo tools of ${agent.id} failed: ${error instanceof Error ? error.message : String(error)}`);
116
+ return "unknown";
117
+ }
118
+ }