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,629 @@
1
+ /**
2
+ * Turn collector: the only writer of the trace store (WP-205, Dashboard Design
3
+ * §2.2 decision 1, §3.3, §12).
4
+ *
5
+ * It hangs off Paseo's own lifecycle hooks — `agent.turn_started` for the start
6
+ * mark and `agent.turn_ended` for the record — so there is no timer, no watcher
7
+ * and no polling loop; the "no background work" rule of the base design §8
8
+ * still holds. The plugin already uses `agent.turn_ended` for stop propagation
9
+ * (bm-wq6), so this is a known-good seam.
10
+ *
11
+ * The hard rule here is that this code runs **inside a real agent's turn**:
12
+ * nothing it does may throw into the host. Out of disk space, no permission, a
13
+ * store written by a newer paseo-bm, a lock it could not get in five seconds —
14
+ * all of them end the same way: the turn is dropped, one line is logged, and
15
+ * the agent is untouched (REQ-053c).
16
+ *
17
+ * SDK facts this relies on (checked against @getpaseo/plugin 0.8.0
18
+ * `server/lifecycle.d.ts` and @getpaseo/client 0.8.0, not guessed):
19
+ * - `on("agent.turn_started" | "agent.turn_ended", (event, { paseo }) => …)`;
20
+ * the ended event carries `agent`, `turnId`, `outcome.kind` and `timeline`.
21
+ * - **The hook `timeline` is the agent's WHOLE timeline with the timestamps
22
+ * stripped.** Verified against the shipped daemon (Paseo 0.8.0, WP-205.2):
23
+ * `publishAgentStream(..., this.timelineStore.getItems(agentId))` and
24
+ * `getItems(agentId) { return this.requireState(agentId).rows.map(row => row.item) }`
25
+ * — the store keeps `{ seq, timestamp, turnId, item }` rows but the hook only
26
+ * receives `row.item`, and it receives every row, not the ended turn's rows.
27
+ * That is assumptions A-1/A-2 of the PRD, and it decides the design here: the
28
+ * turn's own items come from one `timeline.refetch()` call, whose entries do
29
+ * carry `turnId` and `timestamp`, and the hook payload is only a fallback.
30
+ * Using the hook payload as the turn would put the entire conversation into
31
+ * every record and parse every old report again.
32
+ * - the `refetch` payload also carries an `agent` snapshot whose `lastUsage`
33
+ * gives tokens and (sometimes) cost, which is where a record's `usage` comes
34
+ * from.
35
+ */
36
+ import { readFileSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { basename } from "node:path";
39
+ import type { PluginLifecycleEvents, PluginServerContext } from "@getpaseo/plugin/server";
40
+ import { parseReports, parseReviews, requestIdFromText } from "./bm-report";
41
+ import { isPluginNotice } from "./notices";
42
+ import { stripNewRequestMarker } from "../shared/new-request";
43
+ import { resolveInstallHome } from "./install-home";
44
+ import { roleOfProvider } from "./agent-role";
45
+ import { providerId } from "./provider-id";
46
+ import {
47
+ TraceStoreLockTimeout,
48
+ appendRecord,
49
+ writeWorkspaceMeta,
50
+ type TraceStoreLocation,
51
+ } from "./trace-store";
52
+ import {
53
+ TRACE_STORE_SCHEMA_VERSION,
54
+ type Evidence,
55
+ type ParsedReport,
56
+ type ParsedReview,
57
+ type TraceMessage,
58
+ type TraceRecord,
59
+ type TraceRuntime,
60
+ type Usage,
61
+ } from "../shared/contracts";
62
+
63
+ /** Timeline entries read back per turn when timestamps have to be recovered. */
64
+ export const REFETCH_LIMIT = 200;
65
+
66
+ /**
67
+ * Secret masking, applied **before** anything reaches the disk (REQ-048b).
68
+ *
69
+ * Same rule set as the CLI's `src/redact.ts` (Design §7): the *values* of these
70
+ * environment variables, and the value following a password-shaped flag. The
71
+ * plugin payload cannot import `src/`, so the constants are duplicated; they
72
+ * are a short closed list by design.
73
+ */
74
+ export const SECRET_ENV_VARS: readonly string[] = ["PASEO_PASSWORD", "PASEO_DAEMON_PASSWORD"];
75
+ export const SECRET_ARGV_FLAGS: readonly string[] = ["--password", "--token", "--secret"];
76
+ export const REDACTED = "[redacted]";
77
+
78
+ /** Masks every known secret in a string. Never lengthens it beyond the mask. */
79
+ export function redactText(text: string, env: NodeJS.ProcessEnv = process.env): string {
80
+ if (typeof text !== "string" || text === "") return "";
81
+ let out = text;
82
+ for (const name of SECRET_ENV_VARS) {
83
+ const value = env[name];
84
+ if (typeof value === "string" && value.trim() !== "") {
85
+ out = out.split(value).join(REDACTED);
86
+ }
87
+ }
88
+ for (const flag of SECRET_ARGV_FLAGS) {
89
+ // `--password secret`, `--password=secret` and `--password "secret"`.
90
+ const pattern = new RegExp(`(${flag})(\\s*=\\s*|\\s+)("[^"]*"|'[^']*'|\\S+)`, "gi");
91
+ out = out.replace(pattern, `$1$2${REDACTED}`);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ type TurnEndedEvent = PluginLifecycleEvents["agent.turn_ended"];
97
+ type TurnStartedEvent = PluginLifecycleEvents["agent.turn_started"];
98
+ type TimelineItem = TurnEndedEvent["timeline"][number];
99
+
100
+ /** One timeline entry with the timestamp the store needs. */
101
+ export interface TimedItem {
102
+ item: TimelineItem;
103
+ at: string;
104
+ }
105
+
106
+ /** The slice of the SDK the collector uses to recover timestamps and usage. */
107
+ export interface CollectorPaseo {
108
+ agents: {
109
+ ref(agentId: string): {
110
+ timeline: {
111
+ refetch(options: { direction?: string; limit?: number }): Promise<unknown>;
112
+ };
113
+ };
114
+ };
115
+ }
116
+
117
+ export interface CollectorDeps {
118
+ /** Resolved trace store, or null when tracing is disabled (WP-202). */
119
+ location: TraceStoreLocation | null;
120
+ paseo?: CollectorPaseo;
121
+ now?: () => Date;
122
+ env?: NodeJS.ProcessEnv;
123
+ log?: (message: string) => void;
124
+ /** How long to wait for the workspace lock before dropping the turn (design §3.8). */
125
+ lockTimeoutMs?: number;
126
+ }
127
+
128
+ function textOf(item: TimelineItem): string | null {
129
+ if (item.type === "user_message" || item.type === "assistant_message") {
130
+ return typeof item.text === "string" ? item.text : null;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * The tail of a whole-conversation timeline that plausibly belongs to the turn
137
+ * that just ended: from the last `user_message` to the end.
138
+ *
139
+ * Used when there is no turn boundary to filter on: the hook payload carries
140
+ * none, and a refetch whose `turnId` is null has none either. A turn begins
141
+ * with a user message, so the slice is at most one turn.
142
+ */
143
+ export function sliceLastTurn<T>(items: readonly T[], itemOf: (entry: T) => unknown = (entry) => entry): T[] {
144
+ for (let index = items.length - 1; index >= 0; index -= 1) {
145
+ const item = itemOf(items[index]!) as { type?: unknown } | null | undefined;
146
+ if (item?.type === "user_message") return items.slice(index);
147
+ }
148
+ return [...items];
149
+ }
150
+
151
+ /** `…/<name>/SKILL.md` → `<name>`. */
152
+ const SKILL_FILE = /([A-Za-z0-9._-]+)\/SKILL\.md\b/;
153
+ /** Commands that read a file's content; `ls`, `test -f` and `find` only look. */
154
+ const READS_FILE = /^(?:cat|sed|head|tail|less|more|bat|nl)\b/;
155
+
156
+ /**
157
+ * Skills an item loaded.
158
+ *
159
+ * Verified on Paseo 0.8 with Claude Code: a load is a `tool_call` named `Skill`
160
+ * whose `plain_text` detail carries the skill name as `label` ("Launching skill:
161
+ * feature-workflow"). Other providers read the skill's `SKILL.md`; a read of
162
+ * that file counts, a directory listing or an existence check does not (the
163
+ * Manager checks skills that way without loading them).
164
+ */
165
+ export function skillsFromItem(item: TimelineItem): string[] {
166
+ if (item.type !== "tool_call") return [];
167
+ const call = item as { name?: unknown; detail?: { type?: string; label?: unknown; filePath?: unknown; command?: unknown } };
168
+ const detail = call.detail;
169
+ if (call.name === "Skill") {
170
+ const label = detail?.label;
171
+ return typeof label === "string" && label.trim() !== "" ? [label.trim()] : [];
172
+ }
173
+ if (detail?.type === "read" && typeof detail.filePath === "string") {
174
+ const found = SKILL_FILE.exec(detail.filePath);
175
+ return found === null ? [] : [found[1]!];
176
+ }
177
+ if (detail?.type === "shell" && typeof detail.command === "string") {
178
+ const out: string[] = [];
179
+ for (const segment of detail.command.split(/&&|\|\||[;\n|]/)) {
180
+ const trimmed = segment.trim();
181
+ if (!READS_FILE.test(trimmed)) continue;
182
+ const found = SKILL_FILE.exec(trimmed);
183
+ if (found !== null) out.push(found[1]!);
184
+ }
185
+ return out;
186
+ }
187
+ return [];
188
+ }
189
+
190
+ /**
191
+ * Evidence a turn leaves behind: skills loaded, commands run, files written,
192
+ * sub-agents started.
193
+ *
194
+ * This is the raw material for "did this request create beads?" (REQ-044b) and
195
+ * the workflow table (REQ-045), so it records what was *observed*, never a
196
+ * conclusion drawn from it.
197
+ */
198
+ export function evidenceFromItem(item: TimelineItem, agentId: string, at: string): Evidence[] {
199
+ if (item.type !== "tool_call") return [];
200
+ const out: Evidence[] = skillsFromItem(item).map((skill) => ({ kind: "skill", detail: skill, agentId, at }));
201
+ const detail = (item as { detail?: Record<string, unknown> }).detail;
202
+ const text = (key: string): string => {
203
+ const value = detail?.[key];
204
+ return typeof value === "string" ? value.trim() : "";
205
+ };
206
+ switch (detail?.["type"]) {
207
+ case "shell":
208
+ if (text("command") !== "") out.push({ kind: "shell", detail: text("command"), agentId, at });
209
+ break;
210
+ case "edit":
211
+ case "write":
212
+ if (text("filePath") !== "") out.push({ kind: "file", detail: text("filePath"), agentId, at });
213
+ break;
214
+ case "sub_agent": {
215
+ const label = [text("subAgentType"), text("description")].filter((part) => part !== "").join(" — ");
216
+ out.push({ kind: "agent", detail: label === "" ? "sub_agent" : label, agentId, at });
217
+ break;
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ interface RefetchEntry {
224
+ item: unknown;
225
+ turnId?: string;
226
+ timestamp?: string;
227
+ }
228
+
229
+ const nonEmpty = (value: unknown): string | null => (typeof value === "string" && value.trim() !== "" ? value : null);
230
+
231
+ /**
232
+ * What the agent ran on this turn, from the snapshot `timeline.refetch` returns
233
+ * (delta 20260918 §4.2). The provider's running values (`runtimeInfo`) come
234
+ * first and the snapshot's configuration only fills in what they leave empty —
235
+ * a provider-reported `null` never hides a real fallback. `null` when there is
236
+ * no snapshot. Never throws: a malformed snapshot costs the fields, not the turn.
237
+ *
238
+ * `provider` (delta 20260921 §4.2.7, F7) is the snapshot's own `provider`,
239
+ * reduced to the provider id the way every other paseo-bm reader does
240
+ * (`bm-worker/<model>` → `bm-worker`), so a later price lookup can hand it to
241
+ * `providers.listModels` as is. `null` when the snapshot names none.
242
+ */
243
+ export function runtimeOf(snapshot: unknown): TraceRuntime | null {
244
+ try {
245
+ if (snapshot === null || typeof snapshot !== "object") return null;
246
+ const agent = snapshot as Record<string, unknown>;
247
+ const info = (agent["runtimeInfo"] !== null && typeof agent["runtimeInfo"] === "object" ? agent["runtimeInfo"] : {}) as Record<string, unknown>;
248
+ return {
249
+ model: nonEmpty(info["model"]) ?? nonEmpty(agent["model"]),
250
+ thinkingOptionId:
251
+ nonEmpty(info["thinkingOptionId"]) ?? nonEmpty(agent["effectiveThinkingOptionId"]) ?? nonEmpty(agent["thinkingOptionId"]),
252
+ modeId: nonEmpty(info["modeId"]) ?? nonEmpty(agent["currentModeId"]),
253
+ provider: nonEmpty(providerId(agent["provider"])),
254
+ };
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /** Reads `timestamp`s back for one turn with a single `refetch` call (assumption A-2). */
261
+ export async function timestampsForTurn(
262
+ deps: CollectorDeps,
263
+ agentId: string,
264
+ turnId: string | null,
265
+ ): Promise<{ entries: RefetchEntry[]; usage: Usage | null; requestIdLabel: string | null; runtime: TraceRuntime | null }> {
266
+ if (deps.paseo === undefined) return { entries: [], usage: null, requestIdLabel: null, runtime: null };
267
+ let payload: unknown;
268
+ try {
269
+ payload = await deps.paseo.agents.ref(agentId).timeline.refetch({
270
+ direction: "tail",
271
+ limit: REFETCH_LIMIT,
272
+ });
273
+ } catch {
274
+ return { entries: [], usage: null, requestIdLabel: null, runtime: null };
275
+ }
276
+ const asRecord = payload as { entries?: unknown; agent?: unknown } | null;
277
+ const rawEntries = Array.isArray(asRecord?.entries) ? (asRecord?.entries as RefetchEntry[]) : [];
278
+ // A turn id selects the turn exactly. Without one, the entries are cut down
279
+ // the same way the hook payload is — everything from the last user message on.
280
+ //
281
+ // WP-214 acceptance, defect 10: returning `rawEntries` here recorded the
282
+ // ENTIRE conversation in one record. On the acceptance workspace that record
283
+ // held 13 inbound messages and 10 reports, and because its first message was
284
+ // F-1's request it folded into F-1 and multiplied that request's tokens.
285
+ const entries =
286
+ turnId === null
287
+ ? sliceLastTurn(rawEntries, (entry) => entry.item)
288
+ : rawEntries.filter((entry) => entry.turnId === turnId);
289
+ const snapshot = asRecord?.agent as
290
+ | { lastUsage?: Record<string, unknown>; model?: unknown; labels?: Record<string, unknown> }
291
+ | null;
292
+ // The agent's own `bm.requestId` label, set when it was created. A Worker's
293
+ // later turns rarely repeat the id in their text, so without this its records
294
+ // carry no request id and are lost from the trace once the agent is deleted
295
+ // (WP-214, D-8's delete leg: 14 of 14 F-1 Worker records had none).
296
+ const label = snapshot?.labels?.["bm.requestId"];
297
+ const requestIdLabel = typeof label === "string" && label !== "" ? label : null;
298
+ const lastUsage = snapshot?.lastUsage;
299
+ const usage: Usage | null =
300
+ lastUsage === undefined || lastUsage === null
301
+ ? null
302
+ : {
303
+ inputTokens: Number(lastUsage["inputTokens"] ?? 0) || 0,
304
+ cachedInputTokens: Number(lastUsage["cachedInputTokens"] ?? 0) || 0,
305
+ outputTokens: Number(lastUsage["outputTokens"] ?? 0) || 0,
306
+ // `lastUsage.totalCostUsd` is the agent's running SESSION total, not
307
+ // the cost of this turn: across the twelve Manager turns of the
308
+ // WP-214 acceptance run it rose monotonically (0.3956 → 0.4492 → …
309
+ // → 2.2712) while the token counts beside it went up and down per
310
+ // turn. Summing it over a request's turns would multiply the bill,
311
+ // so a per-turn record never claims a provider cost and the request
312
+ // cost is estimated from the per-turn tokens instead (defect 11).
313
+ costUsd: null,
314
+ costBasis: "unavailable",
315
+ model: typeof snapshot?.model === "string" ? (snapshot.model as string) : null,
316
+ pricesUpdatedAt: null,
317
+ };
318
+ return { entries, usage, requestIdLabel, runtime: runtimeOf(asRecord?.agent) };
319
+ }
320
+
321
+ /** In-memory start marks, keyed by agent and turn. Lost on reload, which is fine. */
322
+ const startMarks = new Map<string, string>();
323
+
324
+ function markKey(agentId: string, turnId: string | null): string {
325
+ return `${agentId}::${turnId ?? ""}`;
326
+ }
327
+
328
+ /** Records a turn's start time. */
329
+ export function noteTurnStart(event: TurnStartedEvent, now: () => Date = () => new Date()): void {
330
+ // Only paseo-bm's agents are collected, fallback aliases included (delta 20260921 §4.4.1).
331
+ if (roleOfProvider(event.agent.provider) === null) return;
332
+ startMarks.set(markKey(event.agent.id, event.turnId), now().toISOString());
333
+ }
334
+
335
+ /** Test helper: forget every start mark. */
336
+ export function clearStartMarks(): void {
337
+ startMarks.clear();
338
+ }
339
+
340
+ /**
341
+ * `Continue req-…` at the very start of a relay prompt: the only form
342
+ * `roles/manager.md` uses to resume a Worker (delta 20260917 §5.3). Anchored so
343
+ * a prompt that merely mentions another request is never read as resuming it.
344
+ * Markdown around the keyword or the id (`**Continue**`, a backticked id) is
345
+ * tolerated: models add it, and the WP-214 run lost exact links to exactly that.
346
+ */
347
+ const RELAY_REQUEST_ID = /^\s*[*_`]*Continue[*_`]*\s+[*_`]*(req-\d{8}T\d{6}Z)\b/;
348
+
349
+ /**
350
+ * The request a `send_agent_prompt` tool call resumes, or null. The tool name
351
+ * may carry an MCP prefix (`mcp__paseo__send_agent_prompt`), so only the part
352
+ * after the last `__` is compared.
353
+ */
354
+ export function relayRequestIdOf(item: TimelineItem): string | null {
355
+ if (item.type !== "tool_call") return null;
356
+ const call = item as { name?: unknown; detail?: { input?: unknown } };
357
+ if (typeof call.name !== "string" || call.name.split("__").pop() !== "send_agent_prompt") return null;
358
+ const input = call.detail?.input;
359
+ const prompt = input !== null && typeof input === "object" ? (input as { prompt?: unknown }).prompt : undefined;
360
+ return typeof prompt === "string" ? (RELAY_REQUEST_ID.exec(prompt)?.[1] ?? null) : null;
361
+ }
362
+
363
+ /**
364
+ * Builds the record for one ended turn. Pure apart from the timestamp lookup,
365
+ * so the assembly is testable without a store or a daemon.
366
+ */
367
+ export async function buildRecord(
368
+ event: TurnEndedEvent,
369
+ deps: CollectorDeps,
370
+ ): Promise<{ record: TraceRecord; workspaceName: string | null } | null> {
371
+ const role = roleOfProvider(event.agent.provider);
372
+ if (role === null) return null;
373
+ if (event.agent.workspaceId === null) return null;
374
+
375
+ const now = deps.now ?? (() => new Date());
376
+ const endedAt = now().toISOString();
377
+ const env = deps.env ?? process.env;
378
+
379
+ const items = Array.isArray(event.timeline) ? event.timeline : [];
380
+ const { entries, usage, requestIdLabel, runtime } = await timestampsForTurn(deps, event.agent.id, event.turnId);
381
+
382
+ // Preferred source: the refetch entries for this turn, which carry both the
383
+ // item and its timestamp. Fallback: the hook payload, which is the whole
384
+ // conversation, so it is cut down to the last user message onwards — a turn
385
+ // starts with one, and recording the entire history on every turn would
386
+ // duplicate it in every record.
387
+ const timed: TimedItem[] =
388
+ entries.length > 0
389
+ ? entries
390
+ .filter((entry) => entry.item !== undefined && entry.item !== null)
391
+ .map((entry) => ({
392
+ item: entry.item as TimelineItem,
393
+ at: typeof entry.timestamp === "string" ? entry.timestamp : endedAt,
394
+ }))
395
+ : sliceLastTurn(items).map((item) => ({ item, at: endedAt }));
396
+
397
+ const sent: TraceMessage[] = [];
398
+ const received: TraceMessage[] = [];
399
+ const reports: ParsedReport[] = [];
400
+ const reviews: ParsedReview[] = [];
401
+ const evidence: Evidence[] = [];
402
+ let relayRequestId: string | null = null;
403
+
404
+ for (const { item, at } of timed) {
405
+ evidence.push(...evidenceFromItem(item, event.agent.id, at));
406
+ if (role === "manager" && relayRequestId === null) relayRequestId = relayRequestIdOf(item);
407
+ const text = textOf(item);
408
+ if (text === null) continue;
409
+ // `/bm-worker-new` prefixes the user's request with a flag line so the
410
+ // Manager knows not to fold it into whatever is already running. The flag is
411
+ // the plugin's, the words after it are the user's, so the flag comes off
412
+ // here and the message stays theirs (delta 20260917f §4.1).
413
+ const safe = stripNewRequestMarker(redactText(text, env));
414
+ const message: TraceMessage = { agentId: event.agent.id, at, text: safe, truncated: false };
415
+ if (item.type === "user_message") {
416
+ // Typed in Paseo's app → `clientMessageId`; sent by an agent → none.
417
+ // Verified on the owner's Manager: 15/15 typed vs 43/43 agent reports.
418
+ // The plugin's own notices carry a clientMessageId too (the SDK adds one),
419
+ // so they are recognised by their text instead (review b2).
420
+ message.origin =
421
+ typeof (item as { clientMessageId?: unknown }).clientMessageId === "string" && !isPluginNotice(safe)
422
+ ? "user"
423
+ : "agent";
424
+ sent.push(message);
425
+ } else {
426
+ received.push(message);
427
+ }
428
+ reports.push(...parseReports(safe, { agentId: event.agent.id, at }));
429
+ reviews.push(...parseReviews(safe, { agentId: event.agent.id, at }));
430
+ }
431
+
432
+ const requestIdFromReports = reports.find((report) => report.requestId !== null)?.requestId ?? null;
433
+ // The same tolerant pattern the reconstruction uses: real prompts write the
434
+ // id as "- `requestId`: `req-…`" (WP-214 acceptance finding).
435
+ const requestIdFromPrompt = requestIdFromText(sent.map((message) => message.text).join("\n"));
436
+
437
+ const record: TraceRecord = {
438
+ v: TRACE_STORE_SCHEMA_VERSION,
439
+ kind: "turn",
440
+ at: endedAt,
441
+ workspaceId: event.agent.workspaceId,
442
+ agentId: event.agent.id,
443
+ role,
444
+ turnId: event.turnId,
445
+ // Last resort: the Manager's own `Continue <requestId>.` relay. A user's
446
+ // answer otherwise shows as its own request row until the next report.
447
+ requestId: requestIdLabel ?? requestIdFromReports ?? requestIdFromPrompt ?? relayRequestId,
448
+ parentAgentId: event.agent.parentAgentId,
449
+ agentCreatedAt: null,
450
+ startedAt: startMarks.get(markKey(event.agent.id, event.turnId)) ?? null,
451
+ endedAt,
452
+ outcome: event.outcome.kind,
453
+ sent,
454
+ received,
455
+ reports,
456
+ reviews,
457
+ evidence,
458
+ usage,
459
+ runtime,
460
+ };
461
+
462
+ const cwd = typeof event.agent.cwd === "string" && event.agent.cwd !== "" ? event.agent.cwd : null;
463
+ return { record, workspaceName: cwd === null ? null : basename(cwd) };
464
+ }
465
+
466
+ /**
467
+ * Collects one ended turn: build, append, refresh the workspace label.
468
+ *
469
+ * Every failure is contained here. A lock timeout is expected under load and
470
+ * only costs one trace; anything else is logged with its message so a support
471
+ * question has something to go on.
472
+ */
473
+ export async function collectTurnEnded(event: TurnEndedEvent, deps: CollectorDeps): Promise<boolean> {
474
+ const log = deps.log ?? ((message: string) => console.warn(message));
475
+ try {
476
+ if (deps.location === null) return false;
477
+ const built = await buildRecord(event, deps);
478
+ if (built === null) return false;
479
+
480
+ await appendRecord(deps.location, built.record, { timeoutMs: deps.lockTimeoutMs });
481
+ startMarks.delete(markKey(event.agent.id, event.turnId));
482
+
483
+ try {
484
+ writeWorkspaceMeta(deps.location, built.record.workspaceId, {
485
+ lastKnownName: built.workspaceName,
486
+ lastKnownDirectory: typeof event.agent.cwd === "string" ? event.agent.cwd : null,
487
+ lastSeenAt: built.record.at,
488
+ });
489
+ } catch (error) {
490
+ // Losing the label only costs recognisability of an orphaned workspace.
491
+ log(`[paseo-bm] could not update trace workspace metadata: ${messageOf(error)}`);
492
+ }
493
+ return true;
494
+ } catch (error) {
495
+ if (error instanceof TraceStoreLockTimeout) {
496
+ log(`[paseo-bm] dropped one trace record: ${error.message}`);
497
+ return false;
498
+ }
499
+ log(`[paseo-bm] could not record a trace for agent ${event.agent.id}: ${messageOf(error)}`);
500
+ return false;
501
+ }
502
+ }
503
+
504
+ function messageOf(error: unknown): string {
505
+ return error instanceof Error ? error.message : String(error);
506
+ }
507
+
508
+ /** The part of the server context this needs; `on` is absent on older hosts. */
509
+ export type CollectorHost = Partial<Pick<PluginServerContext, "on" | "before">>;
510
+
511
+ export type LocationResolver = (paseo: unknown) => Promise<TraceStoreLocation | null>;
512
+
513
+ /**
514
+ * Resolves the trace store once and remembers it.
515
+ *
516
+ * `contribute()` has no `paseo`, only the hook context does, so the location
517
+ * cannot be resolved at registration time. A successful answer is cached
518
+ * forever (the install home does not move while the daemon runs); a failure is
519
+ * retried on the next turn, which costs one `config.get()`.
520
+ */
521
+ export function createLocationResolver(
522
+ resolve: (paseo: unknown) => Promise<TraceStoreLocation | null>,
523
+ ): LocationResolver {
524
+ let cached: TraceStoreLocation | null = null;
525
+ return async (paseo: unknown) => {
526
+ if (cached !== null) return cached;
527
+ cached = await resolve(paseo);
528
+ return cached;
529
+ };
530
+ }
531
+
532
+ /** Default resolver: WP-202's install-home lookup, with real `fs` and `os`. */
533
+ export async function resolveLocationFromPaseo(paseo: unknown): Promise<TraceStoreLocation | null> {
534
+ const resolution = await resolveInstallHome({
535
+ paseo: paseo as Parameters<typeof resolveInstallHome>[0]["paseo"],
536
+ fs: { readFileSync: (path, encoding) => readFileSync(path, encoding) },
537
+ homedir,
538
+ });
539
+ return resolution.home === null ? null : { tracesDir: resolution.tracesDir };
540
+ }
541
+
542
+ export interface RegisterCollectorOptions {
543
+ /** Overridden in tests; production uses the install-home resolver. */
544
+ resolveLocation?: LocationResolver;
545
+ now?: () => Date;
546
+ env?: NodeJS.ProcessEnv;
547
+ log?: (message: string) => void;
548
+ lockTimeoutMs?: number;
549
+ /**
550
+ * Runs after a turn's record has been written, so a reader sees that turn.
551
+ * The review budget check hangs here rather than on its own
552
+ * `on("agent.turn_ended")`: two handlers of one event give no ordering
553
+ * guarantee, and a count read before the append is one call short.
554
+ * Its failures are contained like the collector's own.
555
+ */
556
+ onRecorded?: (event: TurnEndedEvent, input: { location: TraceStoreLocation; paseo: unknown }) => unknown;
557
+ }
558
+
559
+ /**
560
+ * Registers both hooks and returns their remover. On a host without `on` it
561
+ * logs one line and returns a no-op, exactly like `registerRoleHook` does for a
562
+ * missing `before`.
563
+ */
564
+ export function registerCollector(
565
+ host: CollectorHost,
566
+ options: RegisterCollectorOptions = {},
567
+ ): () => void {
568
+ const log = options.log ?? ((message: string) => console.warn(message));
569
+ if (typeof host.on !== "function") {
570
+ // Same convention as `registerStopPropagation`: on a host with no
571
+ // lifecycle hooks at all, the role hook's line is the informative one, so
572
+ // this stays quiet instead of turning one problem into three log lines.
573
+ if (typeof host.before === "function") {
574
+ log(
575
+ "[paseo-bm] this Paseo host has no on(\"agent.turn_ended\") hook; the Dashboard will have no trace history.",
576
+ );
577
+ }
578
+ return () => {};
579
+ }
580
+
581
+ const resolveLocation =
582
+ options.resolveLocation ?? createLocationResolver(resolveLocationFromPaseo);
583
+ let warnedDisabled = false;
584
+
585
+ const removeStarted = host.on("agent.turn_started", (event) => {
586
+ try {
587
+ noteTurnStart(event, options.now);
588
+ } catch {
589
+ // A missing start mark only costs one duration.
590
+ }
591
+ });
592
+
593
+ const removeEnded = host.on("agent.turn_ended", async (event, context) => {
594
+ try {
595
+ const location = await resolveLocation((context as { paseo?: unknown } | undefined)?.paseo);
596
+ if (location === null) {
597
+ if (!warnedDisabled) {
598
+ warnedDisabled = true;
599
+ log("[paseo-bm] trace store unavailable; the Dashboard will have no trace history.");
600
+ }
601
+ return;
602
+ }
603
+ const paseo = (context as { paseo?: CollectorPaseo } | undefined)?.paseo;
604
+ const recorded = await collectTurnEnded(event, {
605
+ location,
606
+ paseo,
607
+ now: options.now,
608
+ env: options.env,
609
+ log,
610
+ lockTimeoutMs: options.lockTimeoutMs,
611
+ });
612
+ if (recorded && options.onRecorded !== undefined) {
613
+ try {
614
+ await options.onRecorded(event, { location, paseo });
615
+ } catch (error) {
616
+ log(`[paseo-bm] after-record step failed: ${messageOf(error)}`);
617
+ }
618
+ }
619
+ } catch (error) {
620
+ // Last line of defence: this hook runs inside a real agent turn.
621
+ log(`[paseo-bm] trace collection failed: ${messageOf(error)}`);
622
+ }
623
+ });
624
+
625
+ return () => {
626
+ if (typeof removeStarted === "function") removeStarted();
627
+ if (typeof removeEnded === "function") removeEnded();
628
+ };
629
+ }