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,238 @@
1
+ /**
2
+ * Model rates from Paseo's own model list (delta 20260921 §4.2.7, F2, REQ-063 g).
3
+ *
4
+ * The bundled table in `shared/prices.ts` only knows Claude models. Paseo lists
5
+ * every model of a provider with `providers.listModels(provider)`, and a model
6
+ * there may carry `metadata.cost = { input, output, cache: { read, write },
7
+ * tiers?, experimentalOver200K? }` in US dollars per million tokens (every
8
+ * OpenCode model does). `costOf` turns that into a `ModelPrice`:
9
+ *
10
+ * - `input` → `inputUsdPerMTok`, `output` → `outputUsdPerMTok`;
11
+ * - `cache.read` → `cacheReadUsdPerMTok`, or the input rate when the model
12
+ * lists no cache-read rate (never a guessed discount);
13
+ * - `cache.write`, `tiers` and `experimentalOver200K` are ignored: the
14
+ * Dashboard's usage has no cache-write count, and a tier is a surcharge above
15
+ * a context size the usage does not record.
16
+ *
17
+ * Model ids are matched whole: an OpenCode id contains `/`
18
+ * (`anthropic/claude-sonnet-4-6`), so the part after a slash is never enough.
19
+ *
20
+ * Each provider's list is asked for ONCE per plugin run and remembered,
21
+ * whatever the answer, so a failure costs one `console.warn` line and not one
22
+ * per Dashboard refresh. Every wait is raced against `LOOKUP_TIMEOUT_MS`; a
23
+ * slow call keeps running, and a later lookup uses its answer once it lands.
24
+ * Nothing here throws.
25
+ *
26
+ * The Dashboard prices synchronously (`priceUsage`), so its handlers resolve
27
+ * the listed rates of the models their records name first
28
+ * (`listedPricesFor`) and hand the result in as a plain map.
29
+ */
30
+ import { priceFor, type ListedPrices } from "./cost";
31
+ import { providerId } from "./provider-id";
32
+ import { LOOKUP_TIMEOUT_MS, TIMED_OUT, withTimeout } from "./role-mode";
33
+ import { effectiveModel } from "./traces";
34
+ import { modelPriceSchema, type ModelPrice } from "../shared/prices";
35
+ import type { TraceRecord } from "../shared/contracts";
36
+
37
+ /** What one provider's model list gave: rates by model id, or why there are none. */
38
+ type ModelList = { prices: Map<string, ModelPrice>; readAt: string } | { failure: string };
39
+
40
+ interface ListEntry {
41
+ answer: Promise<ModelList>;
42
+ warned: boolean;
43
+ }
44
+
45
+ /** One entry per provider id for the life of the plugin. */
46
+ const lists = new Map<string, ListEntry>();
47
+
48
+ /** Forgets every remembered list; for tests. */
49
+ export function forgetModelCosts(): void {
50
+ lists.clear();
51
+ }
52
+
53
+ const isRate = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
54
+
55
+ /**
56
+ * A `ModelPrice` from one model's `metadata.cost`, or null when the input or
57
+ * output rate is missing or not a usable number.
58
+ */
59
+ export function priceOfCost(cost: unknown): ModelPrice | null {
60
+ if (cost === null || typeof cost !== "object") return null;
61
+ const { input, output, cache } = cost as { input?: unknown; output?: unknown; cache?: unknown };
62
+ if (!isRate(input) || !isRate(output)) return null;
63
+ const read = cache !== null && typeof cache === "object" ? (cache as { read?: unknown }).read : undefined;
64
+ const parsed = modelPriceSchema.safeParse({
65
+ inputUsdPerMTok: input,
66
+ cacheReadUsdPerMTok: isRate(read) ? read : input,
67
+ outputUsdPerMTok: output,
68
+ });
69
+ return parsed.success ? parsed.data : null;
70
+ }
71
+
72
+ /** Rates by model id (and alias, when no id claims it) from a `listModels` answer. */
73
+ function parseModelList(answer: unknown): ModelList {
74
+ const result = answer as { models?: unknown; error?: unknown; fetchedAt?: unknown } | null | undefined;
75
+ if (typeof result?.error === "string" && result.error !== "") return { failure: result.error };
76
+ if (!Array.isArray(result?.models)) return { failure: "no models listed" };
77
+ const prices = new Map<string, ModelPrice>();
78
+ const aliases: Array<[string, ModelPrice]> = [];
79
+ for (const model of result.models as unknown[]) {
80
+ if (model === null || typeof model !== "object") continue;
81
+ const { id, aliases: names, metadata } = model as { id?: unknown; aliases?: unknown; metadata?: unknown };
82
+ if (typeof id !== "string" || id === "") continue;
83
+ const price = priceOfCost(metadata !== null && typeof metadata === "object" ? (metadata as { cost?: unknown }).cost : undefined);
84
+ if (price === null) continue;
85
+ if (!prices.has(id)) prices.set(id, price);
86
+ if (Array.isArray(names)) {
87
+ for (const name of names) if (typeof name === "string" && name !== "") aliases.push([name, price]);
88
+ }
89
+ }
90
+ for (const [name, price] of aliases) if (!prices.has(name)) prices.set(name, price);
91
+ const fetchedAt = typeof result?.fetchedAt === "string" && result.fetchedAt !== "" ? result.fetchedAt : null;
92
+ return { prices, readAt: fetchedAt ?? new Date().toISOString() };
93
+ }
94
+
95
+ /** Asks for one provider's list. Never rejects: a failure is a value. */
96
+ async function readModelList(paseo: unknown, provider: string, cwd: string | undefined): Promise<ModelList> {
97
+ const providers = (paseo as { providers?: { listModels?: unknown } } | null | undefined)?.providers;
98
+ const listModels = providers?.listModels;
99
+ if (typeof listModels !== "function") return { failure: "this Paseo host cannot list models" };
100
+ try {
101
+ return parseModelList(
102
+ await (cwd === undefined ? listModels.call(providers, provider) : listModels.call(providers, provider, { cwd })),
103
+ );
104
+ } catch (error) {
105
+ return { failure: error instanceof Error ? error.message : String(error) };
106
+ }
107
+ }
108
+
109
+ function warnOnce(entry: ListEntry, log: (message: string) => void, message: string): void {
110
+ if (entry.warned) return;
111
+ entry.warned = true;
112
+ log(message);
113
+ }
114
+
115
+ /**
116
+ * The listed rate of `model` on `provider` and when the list was read, or
117
+ * null. `provider` may be a bare id (`bm-worker`) or a selection
118
+ * (`bm-worker/<model>`); a bm-* alias is passed as is, since Paseo resolves it
119
+ * the way it does for `listModes`.
120
+ */
121
+ async function listedCostOf(
122
+ paseo: unknown,
123
+ provider: string,
124
+ model: string,
125
+ log: (message: string) => void,
126
+ cwd: string | undefined,
127
+ ): Promise<{ price: ModelPrice; readAt: string } | null> {
128
+ try {
129
+ const id = providerId(provider);
130
+ if (id === null || id === "" || model === "") return null;
131
+ let entry = lists.get(id);
132
+ if (entry === undefined) {
133
+ entry = { answer: readModelList(paseo, id, cwd), warned: false };
134
+ lists.set(id, entry);
135
+ }
136
+ const list = await withTimeout(entry.answer);
137
+ if (list === TIMED_OUT) {
138
+ warnOnce(
139
+ entry,
140
+ log,
141
+ `[paseo-bm] reading the models of ${id} took longer than ${LOOKUP_TIMEOUT_MS} ms; models missing from the bundled price table show tokens only.`,
142
+ );
143
+ return null;
144
+ }
145
+ if ("failure" in list) {
146
+ warnOnce(
147
+ entry,
148
+ log,
149
+ `[paseo-bm] could not read the models of ${id} (${list.failure}); models missing from the bundled price table show tokens only.`,
150
+ );
151
+ return null;
152
+ }
153
+ const price =
154
+ list.prices.get(model) ?? (model.startsWith(`${id}/`) ? list.prices.get(model.slice(id.length + 1)) : undefined);
155
+ return price === undefined ? null : { price, readAt: list.readAt };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The rate Paseo lists for `model` on `provider`, or null when it lists none
163
+ * or the list cannot be read in time. Does not consult the bundled table: the
164
+ * order "table first" is the caller's (`priceUsage`, `listedPricesFor`).
165
+ */
166
+ export async function costOf(
167
+ paseo: unknown,
168
+ provider: string,
169
+ model: string,
170
+ log: (message: string) => void = (message) => console.warn(message),
171
+ cwd?: string,
172
+ ): Promise<ModelPrice | null> {
173
+ return (await listedCostOf(paseo, provider, model, log, cwd))?.price ?? null;
174
+ }
175
+
176
+ const lastSegment = (model: string): string => model.slice(model.lastIndexOf("/") + 1);
177
+
178
+ const samePrice = (a: ModelPrice, b: ModelPrice): boolean =>
179
+ a.inputUsdPerMTok === b.inputUsdPerMTok &&
180
+ a.cacheReadUsdPerMTok === b.cacheReadUsdPerMTok &&
181
+ a.outputUsdPerMTok === b.outputUsdPerMTok;
182
+
183
+ /**
184
+ * Listed rates for the models `records` ran on, ready for `priceUsage`.
185
+ *
186
+ * Only records that name `runtime.provider` and a model the bundled table
187
+ * lacks are looked up; a record written before `runtime.provider` existed is
188
+ * priced from the table alone. Lookups for distinct providers run together.
189
+ *
190
+ * Keys: the model id as recorded and its last `/` segment, because the trace
191
+ * roll-up groups models by that segment and hands either form to the pricer.
192
+ * When two recorded ids share a segment but their listed rates differ, none of
193
+ * them gets a listed rate: a group mixing both could only be priced wrongly.
194
+ * Never throws; a failure leaves the map without that model.
195
+ */
196
+ export async function listedPricesFor(
197
+ paseo: unknown,
198
+ records: readonly TraceRecord[],
199
+ log: (message: string) => void = (message) => console.warn(message),
200
+ cwd?: string,
201
+ ): Promise<ListedPrices> {
202
+ const out = new Map<string, { price: ModelPrice; updatedAt: string }>();
203
+ try {
204
+ const wanted = new Map<string, { provider: string; model: string }>();
205
+ for (const record of records) {
206
+ const provider = record.runtime?.provider;
207
+ const model = effectiveModel(record);
208
+ if (typeof provider !== "string" || provider === "" || model === null || model === "") continue;
209
+ if (priceFor(model) !== null) continue;
210
+ wanted.set(JSON.stringify([provider, model]), { provider, model });
211
+ }
212
+ if (wanted.size === 0) return out;
213
+
214
+ const found = await Promise.all(
215
+ [...wanted.values()].map(async ({ provider, model }) => ({
216
+ model,
217
+ listed: await listedCostOf(paseo, provider, model, log, cwd),
218
+ })),
219
+ );
220
+ const bySegment = new Map<string, Array<{ model: string; price: ModelPrice; updatedAt: string }>>();
221
+ for (const { model, listed } of found) {
222
+ if (listed === null) continue;
223
+ const segment = lastSegment(model);
224
+ const group = bySegment.get(segment) ?? [];
225
+ group.push({ model, price: listed.price, updatedAt: listed.readAt.slice(0, 10) });
226
+ bySegment.set(segment, group);
227
+ }
228
+ for (const [segment, group] of bySegment) {
229
+ const [first] = group;
230
+ if (first === undefined || !group.every((entry) => samePrice(entry.price, first.price))) continue;
231
+ out.set(segment, { price: first.price, updatedAt: first.updatedAt });
232
+ for (const entry of group) out.set(entry.model, { price: entry.price, updatedAt: entry.updatedAt });
233
+ }
234
+ } catch {
235
+ // A pricing extra: without it the Dashboard shows tokens only, as before.
236
+ }
237
+ return out;
238
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The notice queue: the one way the plugin delivers a notice to an agent that
3
+ * may be running (delta 20260921 §4.2.4, §4.3.5, §4.4.6, F13; REQ-063 d,
4
+ * REQ-064 d, REQ-065 c).
5
+ *
6
+ * `PaseoAgentHandle.send()` on a running agent REPLACES its turn (delta
7
+ * 20260918g K10; `stop-propagation.ts` relies on exactly that to interrupt a
8
+ * Reviewer). `BM-TOOLS`, `BM-SETTINGS` and `BM-FALLBACK` go to Managers and
9
+ * Workers that are often mid-turn, so none of them calls `send()` itself.
10
+ *
11
+ * API — what consumer modules import:
12
+ *
13
+ * - `enqueue(targetId, kind, text, paseo?)` → `Promise<NoticeOutcome>`, never
14
+ * rejects. `refresh()`es the target (as `review-budget.ts` does before its
15
+ * send); when it is not `running` or `initializing` the notice is sent now
16
+ * (`"sent"`), otherwise `{ kind, text }` is held in memory (`"queued"`) and
17
+ * sent at a later `agent.turn_ended` of that target. `kind` is the notice's
18
+ * marker (`"BM-TOOLS"`, `"BM-SETTINGS"`, `"BM-FALLBACK"`). `paseo` is the
19
+ * handle of the caller's own hook or RPC context (`context.paseo`, the
20
+ * `{ paseo }` of a handler); without it the queue uses the last handle a
21
+ * hook gave it, and with none yet the notice waits for the target's turn end,
22
+ * whose hook context brings one. `"dropped"`: bad input, the target is
23
+ * archived, closed or gone, or its send failed. `"replaced"`: a newer notice
24
+ * of the same kind for the same target took its place before it went out.
25
+ * - `noticeQueue`: the one shared queue `enqueue` uses. `createNoticeQueue()`
26
+ * makes a private one (tests).
27
+ * - `registerNoticeQueue(host)` → `{ host, remove }`: the queue does not add a
28
+ * lifecycle hook of its own. It rides on an existing `agent.turn_ended` hook:
29
+ * pass the returned `host` to the module that registers one (index.server.ts
30
+ * uses the BM-FORMAT check), and every turn end that hook sees then also
31
+ * delivers the queue, AFTER that module's handler. So a BM-FORMAT notice sent
32
+ * at the same turn end is already running when the queue `refresh()`es, and
33
+ * the queue waits instead of replacing it. `remove()` detaches the queue and
34
+ * forgets what it holds.
35
+ *
36
+ * Rules this module must never lose:
37
+ * - **Never into a running turn.** Nothing is sent unless `refresh()`, read
38
+ * just before, says the target is neither `running` nor `initializing`.
39
+ * - **One notice per idle moment.** The notice sent starts a turn on the
40
+ * target, and a second `send()` would replace that turn; so each chance
41
+ * sends the oldest queued notice only, and that turn's end carries the next
42
+ * (the lesson `review-budget.ts` records). Queued notices therefore go out
43
+ * in order, one turn apart. A send that fails costs one log line and drops
44
+ * that notice; the next queued one is tried at once, after a fresh
45
+ * `refresh()`.
46
+ * - **The newest notice of a kind wins.** A new notice replaces a queued one of
47
+ * the SAME kind for the SAME target and moves to the back of that target's
48
+ * queue; other kinds and other targets are kept.
49
+ * - **Lifecycle belongs to the user (ADR-005).** `send()` un-archives an
50
+ * archived agent and starts a turn on it, so an archived, closed or unknown
51
+ * target is never sent to: its queued notices are dropped with one log line.
52
+ * - **Keyed by agent id and kind, never by turn id.** Paseo reuses turn ids
53
+ * inside one agent (AGENTS.md).
54
+ * - **In memory only.** A plugin reload loses the queue; each consumer
55
+ * documents its own fallback.
56
+ * - **Never throws.** Every failure costs one `console.warn` line that starts
57
+ * with `[paseo-bm]`.
58
+ */
59
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
60
+
61
+ /** A notice's marker, for example `"BM-TOOLS"`; one queued notice per kind and target. */
62
+ export type NoticeKind = string;
63
+
64
+ export type NoticeOutcome = "sent" | "queued" | "replaced" | "dropped";
65
+
66
+ /** The snapshot fields this module reads; `PaseoAgent` is structurally assignable. */
67
+ export interface NoticeAgentSnapshot {
68
+ status?: string | null;
69
+ archivedAt?: string | null;
70
+ }
71
+
72
+ /** The SDK slice this module uses; `PaseoApi` from a hook or handler context is structurally assignable. */
73
+ export interface NoticePaseo {
74
+ agents: {
75
+ ref(agentId: string): {
76
+ refresh(): Promise<{ agent?: NoticeAgentSnapshot | null } | null>;
77
+ send(text: string): Promise<void>;
78
+ };
79
+ };
80
+ }
81
+
82
+ /** A queued notice, as `pending()` shows it. */
83
+ export interface QueuedNotice {
84
+ kind: NoticeKind;
85
+ text: string;
86
+ }
87
+
88
+ export interface NoticeQueue {
89
+ /** Sends now when the target is idle, otherwise holds the notice for its next turn end. Never rejects. */
90
+ enqueue(targetId: string, kind: NoticeKind, text: string, paseo?: NoticePaseo): Promise<NoticeOutcome>;
91
+ /** One `agent.turn_ended`: delivers the ended agent's queued notices when it is idle. Never rejects. */
92
+ turnEnded(event: unknown, paseo?: unknown): Promise<void>;
93
+ /** What is queued for `targetId`, oldest first (a copy). */
94
+ pending(targetId: string): QueuedNotice[];
95
+ /** Forgets every queued notice and the remembered Paseo handle. */
96
+ clear(): void;
97
+ }
98
+
99
+ export interface NoticeQueueDeps {
100
+ /** Where failures are reported. Defaults to `console.warn`. */
101
+ log?: (message: string) => void;
102
+ }
103
+
104
+ interface Entry extends QueuedNotice {
105
+ state: "queued" | "sending" | NoticeOutcome;
106
+ }
107
+
108
+ type DeliveryStep = "empty" | "unknown" | "gone" | "busy" | "sent" | "failed";
109
+
110
+ function describeError(error: unknown): string {
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+
114
+ function nonEmpty(value: unknown): value is string {
115
+ return typeof value === "string" && value.trim() !== "";
116
+ }
117
+
118
+ function isPaseo(value: unknown): value is NoticePaseo {
119
+ if (value === null || typeof value !== "object") return false;
120
+ const agents = (value as { agents?: unknown }).agents;
121
+ return agents !== null && typeof agents === "object" && typeof (agents as { ref?: unknown }).ref === "function";
122
+ }
123
+
124
+ function agentIdOf(event: unknown): string | null {
125
+ if (event === null || typeof event !== "object") return null;
126
+ const agent = (event as { agent?: unknown }).agent;
127
+ if (agent === null || typeof agent !== "object") return null;
128
+ const id = (agent as { id?: unknown }).id;
129
+ return nonEmpty(id) ? id : null;
130
+ }
131
+
132
+ function isBusy(status: string | null | undefined): boolean {
133
+ return status === "running" || status === "initializing";
134
+ }
135
+
136
+ export function createNoticeQueue(deps: NoticeQueueDeps = {}): NoticeQueue {
137
+ const log = deps.log ?? ((message: string) => console.warn(message));
138
+ /** Target agent id → its queued notices, oldest first. */
139
+ const queued = new Map<string, Entry[]>();
140
+ /** Targets being delivered to right now; `again` asks that delivery for one more look. */
141
+ const delivering = new Map<string, { again: boolean }>();
142
+ let lastPaseo: NoticePaseo | null = null;
143
+
144
+ function handleFor(paseo: unknown): NoticePaseo | null {
145
+ if (isPaseo(paseo)) lastPaseo = paseo;
146
+ return lastPaseo;
147
+ }
148
+
149
+ function put(targetId: string, entry: Entry): void {
150
+ const kept: Entry[] = [];
151
+ for (const old of queued.get(targetId) ?? []) {
152
+ if (old.kind === entry.kind) old.state = "replaced";
153
+ else kept.push(old);
154
+ }
155
+ kept.push(entry);
156
+ queued.set(targetId, kept);
157
+ }
158
+
159
+ function take(targetId: string): Entry | undefined {
160
+ const list = queued.get(targetId);
161
+ const first = list?.shift();
162
+ if (list !== undefined && list.length === 0) queued.delete(targetId);
163
+ return first;
164
+ }
165
+
166
+ function dropAll(targetId: string): void {
167
+ const list = queued.get(targetId) ?? [];
168
+ queued.delete(targetId);
169
+ if (list.length === 0) return;
170
+ for (const entry of list) entry.state = "dropped";
171
+ log(
172
+ `[paseo-bm] ${targetId} is archived, closed or gone; dropped its queued notices (${list.map((entry) => entry.kind).join(", ")}).`,
173
+ );
174
+ }
175
+
176
+ /** One look at the target: sends its oldest notice when it is idle. */
177
+ async function step(targetId: string, paseo: NoticePaseo): Promise<DeliveryStep> {
178
+ const head = queued.get(targetId)?.[0];
179
+ if (head === undefined) return "empty";
180
+ let agent: NoticeAgentSnapshot | null;
181
+ try {
182
+ agent = (await paseo.agents.ref(targetId).refresh())?.agent ?? null;
183
+ } catch (error) {
184
+ log(`[paseo-bm] could not read ${targetId} to deliver its ${head.kind} notice: ${describeError(error)}; it waits for that agent's next turn end.`);
185
+ return "unknown";
186
+ }
187
+ if (agent === null || agent.archivedAt != null || agent.status === "closed") {
188
+ dropAll(targetId);
189
+ return "gone";
190
+ }
191
+ if (isBusy(agent.status)) return "busy";
192
+ const entry = take(targetId);
193
+ if (entry === undefined) return "empty";
194
+ // Taken off the queue BEFORE the await: a turn end handled meanwhile never sends it twice.
195
+ entry.state = "sending";
196
+ try {
197
+ await paseo.agents.ref(targetId).send(entry.text);
198
+ entry.state = "sent";
199
+ return "sent";
200
+ } catch (error) {
201
+ entry.state = "dropped";
202
+ log(`[paseo-bm] could not send the ${entry.kind} notice to ${targetId}: ${describeError(error)}; dropped it.`);
203
+ return "failed";
204
+ }
205
+ }
206
+
207
+ async function deliver(targetId: string, paseo: NoticePaseo): Promise<void> {
208
+ const current = delivering.get(targetId);
209
+ if (current !== undefined) {
210
+ // Another delivery is reading this target: it takes one more look when it is done.
211
+ current.again = true;
212
+ return;
213
+ }
214
+ const flag = { again: false };
215
+ delivering.set(targetId, flag);
216
+ try {
217
+ for (;;) {
218
+ flag.again = false;
219
+ const result = await step(targetId, paseo);
220
+ // The notice started a turn; that turn's end carries the next one.
221
+ if (result === "sent" || result === "empty" || result === "gone") return;
222
+ // The target stayed idle: the next queued notice may go now.
223
+ if (result === "failed") continue;
224
+ // Busy or unreadable: wait for its next turn end, unless something changed meanwhile.
225
+ if (!flag.again) return;
226
+ }
227
+ } finally {
228
+ delivering.delete(targetId);
229
+ }
230
+ }
231
+
232
+ async function enqueue(targetId: string, kind: NoticeKind, text: string, paseo?: NoticePaseo): Promise<NoticeOutcome> {
233
+ try {
234
+ if (!nonEmpty(targetId) || !nonEmpty(kind) || !nonEmpty(text)) {
235
+ log("[paseo-bm] a plugin notice without a target, a kind or a text was not queued.");
236
+ return "dropped";
237
+ }
238
+ const entry: Entry = { kind, text, state: "queued" };
239
+ put(targetId, entry);
240
+ const handle = handleFor(paseo);
241
+ if (handle !== null) await deliver(targetId, handle);
242
+ return entry.state === "sending" ? "queued" : entry.state;
243
+ } catch (error) {
244
+ log(`[paseo-bm] queueing the ${String(kind)} notice for ${String(targetId)} failed: ${describeError(error)}`);
245
+ return "dropped";
246
+ }
247
+ }
248
+
249
+ async function turnEnded(event: unknown, paseo?: unknown): Promise<void> {
250
+ try {
251
+ const handle = handleFor(paseo);
252
+ const targetId = agentIdOf(event);
253
+ if (targetId === null || handle === null || !queued.has(targetId)) return;
254
+ await deliver(targetId, handle);
255
+ } catch (error) {
256
+ log(`[paseo-bm] delivering queued notices failed: ${describeError(error)}`);
257
+ }
258
+ }
259
+
260
+ return {
261
+ enqueue,
262
+ turnEnded,
263
+ pending: (targetId) => (queued.get(targetId) ?? []).map(({ kind, text }) => ({ kind, text })),
264
+ clear() {
265
+ for (const list of queued.values()) for (const entry of list) entry.state = "dropped";
266
+ queued.clear();
267
+ lastPaseo = null;
268
+ },
269
+ };
270
+ }
271
+
272
+ /** The queue every consumer shares; `registerNoticeQueue` delivers it by default. */
273
+ export const noticeQueue: NoticeQueue = createNoticeQueue();
274
+
275
+ /** `noticeQueue.enqueue`: send now when `targetId` is idle, otherwise at its next turn end. Never rejects. */
276
+ export function enqueue(targetId: string, kind: NoticeKind, text: string, paseo?: NoticePaseo): Promise<NoticeOutcome> {
277
+ return noticeQueue.enqueue(targetId, kind, text, paseo);
278
+ }
279
+
280
+ export type NoticeHost = Partial<Pick<PluginServerContext, "on">>;
281
+
282
+ export interface NoticeQueueRegistration {
283
+ /** Hand this to the module whose `agent.turn_ended` hook the queue rides on. */
284
+ host: NoticeHost;
285
+ /** Detaches the queue from that hook and forgets what it holds. */
286
+ remove(): void;
287
+ }
288
+
289
+ /**
290
+ * Lets the queue ride on an `agent.turn_ended` hook another module registers
291
+ * through the returned `host`: after that module's handler settles, the same
292
+ * turn end delivers the queue. Every other registration passes through
293
+ * untouched. On a host without `on` the queue is never delivered at turn ends;
294
+ * `enqueue` still sends to an idle target.
295
+ */
296
+ export function registerNoticeQueue(host: NoticeHost, queue: NoticeQueue = noticeQueue): NoticeQueueRegistration {
297
+ let attached = true;
298
+ const remove = (): void => {
299
+ attached = false;
300
+ queue.clear();
301
+ };
302
+ if (typeof host.on !== "function") return { host, remove };
303
+ const register = host.on.bind(host);
304
+ const on: PluginServerContext["on"] = (name, handler) => {
305
+ if (name !== "agent.turn_ended") return register(name, handler);
306
+ return register(name, async (event, context) => {
307
+ try {
308
+ await handler(event, context);
309
+ } finally {
310
+ if (attached) await queue.turnEnded(event, context?.paseo);
311
+ }
312
+ });
313
+ };
314
+ return { host: { on }, remove };
315
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Messages the PLUGIN sends to an agent, and how to recognise them again.
3
+ *
4
+ * Paseo's SDK always attaches a `messageId` to `PaseoAgentHandle.send()`, and
5
+ * the daemon stores it as `clientMessageId` — the very field the collector uses
6
+ * to tell a message the user typed from one an agent relayed (AGENTS.md). So
7
+ * without this list the plugin's own notices are recorded as the user's words:
8
+ * the Metric screen shows "you" saying `BM-BUDGET …`, counts it among the
9
+ * user's messages, and can even take it for the request text (review b2).
10
+ *
11
+ * Kept in its own module so the pure modules (`traces.ts`) and the senders
12
+ * (`stop-propagation.ts`, `review-budget.ts`) can share one definition without
13
+ * importing each other.
14
+ */
15
+
16
+ /** First word of the review-budget notice (`review-budget.ts`). */
17
+ export const BUDGET_NOTICE_MARKER = "BM-BUDGET";
18
+
19
+ /** Start of the stop notice a Worker's Reviewers receive (`stop-propagation.ts`). */
20
+ export const REVIEWER_STOP_NOTICE_PREFIX = "STOP: The Beads Worker that created you was stopped";
21
+
22
+ /** First word of the stop notice a Worker receives from `/bm-worker-stop-all`. */
23
+ export const WORKER_STOP_NOTICE_MARKER = "BM-STOP";
24
+
25
+ /**
26
+ * What `/bm-worker-stop-all` sends to a running Worker.
27
+ *
28
+ * It POINTS AT the Worker's own stop rule rather than restating it. Restating
29
+ * would create a second procedure that drifts from `roles/worker.md`: the first
30
+ * draft of delta 20260917e did exactly that and its wording would have deleted
31
+ * the mandatory `cancel_agent` on the Worker's own Reviewers, and asked for a
32
+ * `blocked` report that `manager.md` renders as a question list — for a stop
33
+ * that has no questions.
34
+ */
35
+ export const WORKER_STOP_NOTICE =
36
+ `${WORKER_STOP_NOTICE_MARKER} The user asked every Beads Worker and Reviewer in this workspace to stop. This is a stop: follow your Stop rule.`;
37
+
38
+ /**
39
+ * First word of the notice the sender of a block that breaks its template
40
+ * receives (`format-check.ts`, delta 20260918g §4.7). Listed here so the trace
41
+ * store never records it as the user's words and `reviewCallsOf` never counts
42
+ * one sent to a Reviewer as a review call.
43
+ */
44
+ export const FORMAT_NOTICE_MARKER = "BM-FORMAT";
45
+
46
+ /**
47
+ * First word of the notice a Manager receives when a Worker it created has no
48
+ * Paseo tools (`tools-check.ts`, delta 20260921 §4.2.4).
49
+ */
50
+ export const TOOLS_NOTICE_MARKER = "BM-TOOLS";
51
+
52
+ /**
53
+ * First word of the notice a live Manager or Worker receives when the user
54
+ * changed the start mode of the agents it creates (`settings-notices.ts`,
55
+ * delta 20260921 §4.3.5).
56
+ */
57
+ export const SETTINGS_NOTICE_MARKER = "BM-SETTINGS";
58
+
59
+ /**
60
+ * First words of the plugin's fallback notices (delta 20260921 §4.4.6,
61
+ * §4.4.9): `BM-FALLBACK` tells a Manager about an incident and its decision;
62
+ * `BM-RESUME` asks an agent to carry on after its usage reset.
63
+ */
64
+ export const FALLBACK_NOTICE_MARKER = "BM-FALLBACK";
65
+ export const RESUME_NOTICE_MARKER = "BM-RESUME";
66
+
67
+ const PREFIXES: readonly string[] = [
68
+ BUDGET_NOTICE_MARKER,
69
+ REVIEWER_STOP_NOTICE_PREFIX,
70
+ WORKER_STOP_NOTICE_MARKER,
71
+ FORMAT_NOTICE_MARKER,
72
+ TOOLS_NOTICE_MARKER,
73
+ SETTINGS_NOTICE_MARKER,
74
+ FALLBACK_NOTICE_MARKER,
75
+ RESUME_NOTICE_MARKER,
76
+ ];
77
+
78
+ /** True when this text is one of the plugin's own notices, not a person's words. */
79
+ export function isPluginNotice(text: unknown): boolean {
80
+ return typeof text === "string" && PREFIXES.some((prefix) => text.startsWith(prefix));
81
+ }