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,662 @@
1
+ /**
2
+ * Wiring for the Dashboard RPCs (WP-208, WP-210; Dashboard Design §5).
3
+ *
4
+ * One place resolves the trace store and one place turns a thrown
5
+ * `DashboardError` into the coded message the client reads with `errorCodeOf`.
6
+ * Handlers stay thin: the behaviour lives in `trace-store.ts`, `beads-store.ts`
7
+ * and `traces.ts`, which are all testable without a daemon.
8
+ *
9
+ * Workspace directory: `beads.stats` needs the repository path, which only the
10
+ * workspace snapshot has, so it is read through `paseo.workspaces.list()` and
11
+ * matched by id. A workspace that is not in the list is reported as an empty
12
+ * bead store rather than an error — it may simply have been archived.
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
17
+ import { listAllAgents, roleOfAgent } from "./agent-role";
18
+ import { beadStats, lookupBeads } from "./beads-store";
19
+ import { resolveInstallHome } from "./install-home";
20
+ import { priceUsage } from "./cost";
21
+ import { listedPricesFor } from "./model-costs";
22
+ import { inferWorkflowSteps } from "./workflow-steps";
23
+ import { mergeExtras, readLiveExtras } from "./live-timeline";
24
+ import { getBeadDetail, listBeadRows, runBeadAction, type BeadActionPaseo } from "./bead-actions";
25
+ import { beadWorkOf } from "./bead-work";
26
+ import { readAnswerMarks, writeAnswerMark } from "./answer-marks";
27
+ import { stopAllInWorkspace, type StopPaseo } from "./stop-propagation";
28
+ import { readIncidents } from "./fallback-state";
29
+ import { installHomeOf } from "./role-extras";
30
+ import { TIMED_OUT, withTimeout } from "./role-mode";
31
+ import {
32
+ detail,
33
+ paginate,
34
+ reconstructTraces,
35
+ summariseSegments,
36
+ type AgentFacts,
37
+ type ReconstructedTrace,
38
+ } from "./traces";
39
+ import {
40
+ classifyWorkspaces,
41
+ readRecords,
42
+ deleteTraces,
43
+ measureStore,
44
+ reassignWorkspace,
45
+ readWorkspaceMeta,
46
+ recordKeyOf,
47
+ type DeleteScope,
48
+ type TraceStoreLocation,
49
+ type WorkspaceClassification,
50
+ } from "./trace-store";
51
+ import type { FallbackIncident, WorkspaceState } from "../shared/contracts";
52
+ import {
53
+ TRACE_LIST_LIMIT,
54
+ DashboardError,
55
+ beadsStatsRpc,
56
+ beadsListRpc,
57
+ beadsGetRpc,
58
+ beadsActionRpc,
59
+ workspacesOverviewRpc,
60
+ answersMarkRpc,
61
+ answersMarksRpc,
62
+ agentsStopAllRpc,
63
+ tracesGetRpc,
64
+ tracesListRpc,
65
+ tracesWorkspacesRpc,
66
+ tracesDeleteRpc,
67
+ tracesReassignRpc,
68
+ type BeadAction,
69
+ type BeadDetail,
70
+ type BeadRow,
71
+ type BeadStats,
72
+ type WorkspaceOverview,
73
+ type StoreSize,
74
+ type TraceDetail,
75
+ type TraceSummary,
76
+ } from "../shared/contracts";
77
+
78
+ /** The SDK slice these handlers use. `PaseoApi` is structurally assignable. */
79
+ export interface DashboardPaseo {
80
+ agents: {
81
+ /** Absent on hosts (and fakes) without per-agent timeline access. */
82
+ ref?(agentId: string): { timeline: { refetch(options: Record<string, unknown>): Promise<unknown> } };
83
+ list(options: {
84
+ filter: { labels?: Record<string, string>; includeArchived: boolean };
85
+ page: { limit: number; cursor?: string };
86
+ }): Promise<{ entries: Array<Record<string, unknown>>; pageInfo?: { nextCursor: string | null; hasMore: boolean } }>;
87
+ };
88
+ workspaces: {
89
+ list(options?: unknown): Promise<{ entries: Array<Record<string, unknown>> }>;
90
+ };
91
+ config: {
92
+ get(): Promise<{ config: { plugins?: Record<string, unknown> } }>;
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Resolves the trace store for a handler, or throws a coded error.
98
+ *
99
+ * `homedir` is injectable so a test never depends on the machine it runs on:
100
+ * the default fallback path is inside the real user's home, and a real
101
+ * paseo-bm installation there would otherwise make a "cannot resolve" test
102
+ * silently succeed.
103
+ */
104
+ export async function requireLocation(
105
+ paseo: DashboardPaseo,
106
+ deps: { homedir?: () => string } = {},
107
+ ): Promise<TraceStoreLocation> {
108
+ return (await requireInstallHome(paseo, deps)).location;
109
+ }
110
+
111
+ /** `requireLocation`, with the install home it was found in. */
112
+ async function requireInstallHome(
113
+ paseo: DashboardPaseo,
114
+ deps: { homedir?: () => string },
115
+ ): Promise<{ home: string; location: TraceStoreLocation }> {
116
+ const resolution = await resolveInstallHome({
117
+ paseo,
118
+ fs: { readFileSync: (path, encoding) => readFileSync(path, encoding) },
119
+ homedir: deps.homedir ?? homedir,
120
+ });
121
+ if (resolution.home === null) {
122
+ throw new DashboardError(
123
+ "E_TRACE_STORE_UNWRITABLE",
124
+ `the paseo-bm trace store is unavailable: ${resolution.reason}`,
125
+ );
126
+ }
127
+ return { home: resolution.home, location: { tracesDir: resolution.tracesDir } };
128
+ }
129
+
130
+ /**
131
+ * Ids of the Reviewers that replaced one stopped on its provider plan: the
132
+ * `replacementId` of every Reviewer incident that has one (delta 20260921
133
+ * §4.5.1). Their first message is the old Reviewer's review call sent again,
134
+ * which `reviewCallsOf` does not count a second time.
135
+ */
136
+ export function reviewerReplacementIds(incidents: readonly FallbackIncident[]): Set<string> {
137
+ const out = new Set<string>();
138
+ for (const incident of incidents) {
139
+ if (incident.role === "reviewer" && incident.replacementId !== null) out.add(incident.replacementId);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * `reviewerReplacementIds` of `<home>/role-fallback-state.json`. Empty without
146
+ * a home, or when the file is missing or unusable, so the count stays what it
147
+ * was before fallback existed. Silent: fallback detection already logs an
148
+ * unusable file, and a Dashboard refresh or a turn end must not repeat it.
149
+ * Never throws.
150
+ */
151
+ export function reviewerReplacementsIn(home: string | null): Set<string> {
152
+ return home === null ? new Set() : reviewerReplacementIds(readIncidents(home, () => {}).incidents);
153
+ }
154
+
155
+ /**
156
+ * `reviewerReplacementsIn` the install home of `paseo`, looked up within the
157
+ * lookup budget unless `deps.home` names it (tests). Never throws.
158
+ */
159
+ export async function reviewerReplacementsFor(paseo: unknown, deps: { home?: string | null } = {}): Promise<Set<string>> {
160
+ try {
161
+ const found = deps.home !== undefined ? deps.home : await withTimeout(installHomeOf(paseo));
162
+ return reviewerReplacementsIn(found === TIMED_OUT ? null : found);
163
+ } catch {
164
+ return new Set();
165
+ }
166
+ }
167
+
168
+ export interface ListedWorkspace {
169
+ id: string;
170
+ archived: boolean;
171
+ directory: string | null;
172
+ }
173
+
174
+ /**
175
+ * Workspaces Paseo currently lists, or null when the list could not be read.
176
+ *
177
+ * `null` is load-bearing: `classifyWorkspaces` must not label anything orphaned
178
+ * because of one failed call (REQ-057a).
179
+ */
180
+ export async function listedWorkspaces(paseo: DashboardPaseo): Promise<ListedWorkspace[] | null> {
181
+ let entries: Array<Record<string, unknown>>;
182
+ try {
183
+ ({ entries } = await paseo.workspaces.list());
184
+ } catch {
185
+ return null;
186
+ }
187
+ return entries.map((entry) => {
188
+ const workspace = (entry["workspace"] ?? entry) as Record<string, unknown>;
189
+ const archivingAt = workspace["archivingAt"] ?? workspace["archivedAt"];
190
+ // The workspace's own directory. The project root is only the same place
191
+ // for a plain checkout: a worktree's root is the main checkout, and reading
192
+ // beads there would show another workspace's beads.
193
+ const kind = workspace["kind"] ?? workspace["workspaceKind"];
194
+ const keys = kind === "worktree" ? ["directory", "workspaceDirectory", "cwd"] : ["directory", "workspaceDirectory", "cwd", "projectRootPath"];
195
+ const directory = keys
196
+ .map((key) => workspace[key])
197
+ .find((value): value is string => typeof value === "string" && value !== "");
198
+ return {
199
+ id: String(workspace["id"] ?? ""),
200
+ archived: typeof archivingAt === "string" && archivingAt !== "",
201
+ directory: directory ?? null,
202
+ };
203
+ });
204
+ }
205
+
206
+ function directoryIn(listed: readonly ListedWorkspace[] | null, workspaceId: string): string | null {
207
+ return listed?.find((entry) => entry.id === workspaceId)?.directory ?? null;
208
+ }
209
+
210
+ /** Repository path of a workspace, or null when Paseo no longer lists it. */
211
+ export async function workspaceDirectory(paseo: DashboardPaseo, workspaceId: string): Promise<string | null> {
212
+ return directoryIn(await listedWorkspaces(paseo), workspaceId);
213
+ }
214
+
215
+ /** The empty answer for a workspace whose directory cannot be found. */
216
+ export function emptyBeadStats(source: string): BeadStats {
217
+ return {
218
+ total: 0,
219
+ open: 0,
220
+ inProgress: 0,
221
+ blocked: 0,
222
+ closed: 0,
223
+ ready: 0,
224
+ readAt: new Date().toISOString(),
225
+ source,
226
+ skippedLines: 0,
227
+ present: false,
228
+ };
229
+ }
230
+
231
+ /** `beads.stats` handler (REQ-046). Read-only. */
232
+ export async function handleBeadsStats(
233
+ input: { workspaceId: string },
234
+ paseo: DashboardPaseo,
235
+ ): Promise<{ stats: BeadStats }> {
236
+ const directory = await workspaceDirectory(paseo, input.workspaceId);
237
+ if (directory === null) {
238
+ return { stats: emptyBeadStats(`workspace ${input.workspaceId} is not listed by Paseo`) };
239
+ }
240
+ return { stats: beadStats(directory) };
241
+ }
242
+
243
+ /** `traces.delete` handler (REQ-054). The only destructive handler in the plugin. */
244
+ export async function handleTracesDelete(
245
+ input: { workspaceId: string; scope: DeleteScope; dryRun?: boolean },
246
+ paseo: DashboardPaseo,
247
+ deps: { homedir?: () => string } = {},
248
+ ): Promise<{ deleted: { traces: number; bytes: number; running: number }; store: StoreSize }> {
249
+ const { location, traces } = await readTraceContext({ workspaceId: input.workspaceId }, paseo, deps);
250
+ const scope = input.scope;
251
+ const inScope = traces.filter((trace) =>
252
+ "traceId" in scope
253
+ ? trace.traceId === scope.traceId
254
+ : "before" in scope
255
+ ? trace.records.some((record) => record.at < scope.before)
256
+ : true,
257
+ );
258
+ let recordKeys: Set<string> | undefined;
259
+ if ("traceId" in scope) {
260
+ // Delete exactly what the row shows. The row's id is not a store key, and a
261
+ // trace spans several agents and unnamed Manager turns (WP-214 acceptance:
262
+ // the button matched nothing and deleted nothing).
263
+ const [trace] = inScope;
264
+ if (trace === undefined) {
265
+ throw new DashboardError("E_TRACE_NOT_FOUND", `no trace ${scope.traceId} in workspace ${input.workspaceId}`);
266
+ }
267
+ recordKeys = new Set(trace.records.map((record) => recordKeyOf(record)));
268
+ }
269
+ const outcome = await deleteTraces(location, input.workspaceId, scope, {
270
+ dryRun: input.dryRun === true,
271
+ recordKeys,
272
+ });
273
+ return {
274
+ deleted: { ...outcome, running: inScope.filter((trace) => trace.state === "running").length },
275
+ store: measureStore(location, input.workspaceId),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Everything the two read handlers need, gathered once: the store, the agent
281
+ * facts, the workspace's state and the reconstructed traces.
282
+ *
283
+ * `agents.list` is filtered by `bm.role` label exactly as `manager.ts` does, and
284
+ * then by workspace, because the daemon's directory filter has no workspace key.
285
+ */
286
+ export async function readTraceContext(
287
+ input: { workspaceId: string },
288
+ paseo: DashboardPaseo,
289
+ deps: { homedir?: () => string } = {},
290
+ ): Promise<{
291
+ location: TraceStoreLocation;
292
+ traces: ReconstructedTrace[];
293
+ agents: Map<string, AgentFacts>;
294
+ /** Repository path from the same workspace list, or null. */
295
+ directory: string | null;
296
+ /**
297
+ * Directory for reading file evidence in the workflow table: the listed one,
298
+ * else the trace store's `lastKnownDirectory` (delta 20260917 §5.5), so a
299
+ * closed workspace keeps its file-based steps. Never used to read beads.
300
+ */
301
+ evidenceDirectory: string | null;
302
+ workspaceState: WorkspaceState;
303
+ notices: string[];
304
+ store: StoreSize;
305
+ }> {
306
+ const { home, location } = await requireInstallHome(paseo, deps);
307
+ const read = readRecords(location, input.workspaceId);
308
+ const agents = await agentFactsOf(paseo, input.workspaceId);
309
+ const listed = await listedWorkspaces(paseo);
310
+ const classified = classifyWorkspaces(location, listed).find(
311
+ (entry) => entry.workspaceId === input.workspaceId,
312
+ );
313
+ // The same review count as the BM-BUDGET check: a replacement Reviewer's
314
+ // first message is not a new call (delta 20260921 §4.5.1).
315
+ const traces = reconstructTraces({
316
+ records: read.records,
317
+ agents: [...agents.values()],
318
+ replacementIds: reviewerReplacementsIn(home),
319
+ });
320
+ return {
321
+ location,
322
+ traces,
323
+ agents,
324
+ directory: directoryIn(listed, input.workspaceId),
325
+ evidenceDirectory:
326
+ directoryIn(listed, input.workspaceId) ?? readWorkspaceMeta(location, input.workspaceId)?.lastKnownDirectory ?? null,
327
+ workspaceState: classified?.state ?? (listed === null ? "unknown" : "live"),
328
+ notices: read.notices,
329
+ store: measureStore(location, input.workspaceId),
330
+ };
331
+ }
332
+
333
+ /** Agent facts of one workspace, keyed by id (design §6 step 1). */
334
+ export async function agentFactsOf(
335
+ paseo: DashboardPaseo,
336
+ workspaceId: string,
337
+ ): Promise<Map<string, AgentFacts>> {
338
+ const all = await bmAgentsOf(paseo);
339
+ return new Map(all.filter((entry) => entry.workspaceId === workspaceId).map((entry) => [entry.facts.id, entry.facts]));
340
+ }
341
+
342
+ /**
343
+ * Every paseo-bm agent on this host, with its workspace. One walk over every
344
+ * page of `agents.list`, no label filter: the role comes from `roleOfAgent`, so
345
+ * an agent started from Paseo's own new-agent flow with a paseo-bm profile
346
+ * (no `bm.role` label) is found too, marked `labelled: false` (delta 20260918g
347
+ * §4.2). The daemon's directory filter has no workspace key.
348
+ */
349
+ export async function bmAgentsOf(paseo: DashboardPaseo): Promise<Array<{ workspaceId: string | null; facts: AgentFacts }>> {
350
+ let listed: Array<Record<string, unknown>>;
351
+ try {
352
+ listed = await listAllAgents(
353
+ async (options) => {
354
+ const result = await paseo.agents.list(options);
355
+ // Entries are `{ agent }` on the SDK; older fakes hand the agent itself.
356
+ return {
357
+ entries: result.entries.map((entry) => ({ agent: (entry["agent"] ?? entry) as Record<string, unknown> })),
358
+ ...(result.pageInfo === undefined ? {} : { pageInfo: result.pageInfo }),
359
+ };
360
+ },
361
+ { includeArchived: true },
362
+ );
363
+ } catch {
364
+ return [];
365
+ }
366
+ const out: Array<{ workspaceId: string | null; facts: AgentFacts }> = [];
367
+ for (const agent of listed) {
368
+ const fact = roleOfAgent(agent);
369
+ const id = String(agent["id"] ?? "");
370
+ if (fact === null || id === "") continue;
371
+ const labels = (agent["labels"] ?? {}) as Record<string, string>;
372
+ const facts: AgentFacts = {
373
+ id,
374
+ role: fact.role,
375
+ labelled: fact.labelled,
376
+ status: String(agent["status"] ?? "closed"),
377
+ parentAgentId: labels["paseo.parent-agent-id"] ?? (agent["parentAgentId"] as string | null) ?? null,
378
+ createdAt: typeof agent["createdAt"] === "string" ? (agent["createdAt"] as string) : null,
379
+ requestIdLabel: labels["bm.requestId"] ?? null,
380
+ batchIdLabel: labels["bm.batchId"] ?? null,
381
+ archived: typeof agent["archivedAt"] === "string" && agent["archivedAt"] !== "",
382
+ title: typeof agent["title"] === "string" ? (agent["title"] as string) : null,
383
+ replacedBy: labels["bm.replacedBy"] ?? null,
384
+ };
385
+ out.push({ workspaceId: typeof agent["workspaceId"] === "string" ? agent["workspaceId"] : null, facts });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /** `traces.list` handler (REQ-041). Read-only. */
391
+ export async function handleTracesList(
392
+ input: { workspaceId: string; limit?: number; cursor?: string },
393
+ paseo: DashboardPaseo,
394
+ deps: { homedir?: () => string } = {},
395
+ ): Promise<{
396
+ traces: TraceSummary[];
397
+ nextCursor: string | null;
398
+ truncated: boolean;
399
+ store: StoreSize;
400
+ notices: string[];
401
+ }> {
402
+ const context = await readTraceContext(input, paseo, deps);
403
+ const limit = Math.min(input.limit ?? TRACE_LIST_LIMIT, TRACE_LIST_LIMIT);
404
+ const { page, nextCursor, truncated } = paginate(context.traces, limit, input.cursor);
405
+ // Rates Paseo lists for models the bundled table lacks (delta 20260921
406
+ // §4.2.7), read before pricing so pricing itself stays synchronous.
407
+ const listed = await listedPricesFor(
408
+ paseo,
409
+ page.flatMap((trace) => trace.records),
410
+ undefined,
411
+ context.directory ?? undefined,
412
+ );
413
+ return {
414
+ traces: page.flatMap((trace) =>
415
+ summariseSegments(trace, {
416
+ agents: context.agents,
417
+ workspaceState: context.workspaceState,
418
+ reassignedFrom: reassignedFromOf(trace, input.workspaceId),
419
+ priceUsage: (usage) => priceUsage(usage, listed),
420
+ }),
421
+ ),
422
+ nextCursor,
423
+ truncated,
424
+ store: context.store,
425
+ notices: context.notices,
426
+ };
427
+ }
428
+
429
+ /** `traces.get` handler (REQ-043). Unknown id fails `E_TRACE_NOT_FOUND`. */
430
+ export async function handleTracesGet(
431
+ input: { workspaceId: string; traceId: string },
432
+ paseo: DashboardPaseo,
433
+ deps: { homedir?: () => string } = {},
434
+ ): Promise<{ trace: TraceDetail }> {
435
+ const context = await readTraceContext(input, paseo, deps);
436
+ const trace = context.traces.find((candidate) => candidate.traceId === input.traceId);
437
+ if (trace === undefined) {
438
+ throw new DashboardError(
439
+ "E_TRACE_NOT_FOUND",
440
+ `no trace ${input.traceId} in workspace ${input.workspaceId}; the agents it belonged to may have been deleted`,
441
+ );
442
+ }
443
+ const { directory } = context;
444
+ const listed = await listedPricesFor(paseo, trace.records, undefined, directory ?? undefined);
445
+ const built = detail(trace, {
446
+ agents: context.agents,
447
+ workspaceState: context.workspaceState,
448
+ reassignedFrom: reassignedFromOf(trace, input.workspaceId),
449
+ priceUsage: (usage) => priceUsage(usage, listed),
450
+ lookupBeads: (ids) => (directory === null ? { found: [], missing: [...ids] } : lookupBeads(directory, ids)),
451
+ workflowSteps: (reconstructed, beadStatus) =>
452
+ inferWorkflowSteps(reconstructed, { beadStatus, workspaceDir: context.evidenceDirectory }),
453
+ });
454
+ // What the agents did before the collector saw them is still in their
455
+ // timelines: add their skills and the user's messages from there.
456
+ const present = [...trace.workerIds, ...trace.reviewerIds].filter((id) => context.agents.has(id));
457
+ const extras = mergeExtras(built, await readLiveExtras(paseo, present));
458
+ return { trace: { ...built, skills: extras.skills, userMessages: extras.userMessages } };
459
+ }
460
+
461
+ /**
462
+ * The workspace a trace's records were written under, when that differs from
463
+ * the directory it now lives in (REQ-057, `reassignedFrom`).
464
+ */
465
+ export function reassignedFromOf(trace: ReconstructedTrace, workspaceId: string): string | null {
466
+ const original = trace.records.find((record) => record.workspaceId !== workspaceId);
467
+ return original?.workspaceId ?? null;
468
+ }
469
+
470
+ /**
471
+ * `traces.reassign` handler (REQ-057d). Only ever reached by a user action:
472
+ * the destination must be a workspace Paseo lists right now.
473
+ */
474
+ export async function handleTracesReassign(
475
+ input: { fromWorkspaceId: string; toWorkspaceId: string; dryRun?: boolean },
476
+ paseo: DashboardPaseo,
477
+ deps: { homedir?: () => string } = {},
478
+ ): Promise<{ moved: { traces: number; bytes: number }; store: StoreSize }> {
479
+ const location = await requireLocation(paseo, deps);
480
+ const listed = await listedWorkspaces(paseo);
481
+ const outcome = await reassignWorkspace(location, input.fromWorkspaceId, input.toWorkspaceId, {
482
+ dryRun: input.dryRun === true,
483
+ destinationExists: listed === null ? undefined : listed.some((entry) => entry.id === input.toWorkspaceId),
484
+ });
485
+ return {
486
+ moved: { traces: outcome.traces, bytes: outcome.bytes },
487
+ store: measureStore(location, input.toWorkspaceId),
488
+ };
489
+ }
490
+
491
+ /** `traces.workspaces`: stored workspaces with their current state, for the launcher. */
492
+ export async function handleTracesWorkspaces(
493
+ paseo: DashboardPaseo,
494
+ deps: { homedir?: () => string } = {},
495
+ ): Promise<{
496
+ workspaces: Array<WorkspaceClassification & { lastSeenAt: string | null; bytes: number }>;
497
+ }> {
498
+ const location = await requireLocation(paseo, deps);
499
+ const classified = classifyWorkspaces(location, await listedWorkspaces(paseo));
500
+ return {
501
+ workspaces: classified.map((entry) => ({
502
+ ...entry,
503
+ lastSeenAt: readWorkspaceMeta(location, entry.workspaceId)?.lastSeenAt ?? null,
504
+ bytes: measureStore(location, entry.workspaceId).workspaceBytes,
505
+ })),
506
+ };
507
+ }
508
+
509
+ /**
510
+ * `agents.stop-all` handler: asks this workspace's Workers and Reviewers to
511
+ * stop. The Manager is never asked — it is the user's point of contact.
512
+ */
513
+ export async function handleAgentsStopAll(
514
+ input: { workspaceId: string },
515
+ paseo: StopPaseo,
516
+ ): Promise<{ workers: number; reviewers: number; skipped: number }> {
517
+ return stopAllInWorkspace(paseo, input.workspaceId);
518
+ }
519
+
520
+ /** `answers.marks` handler: the cards marked as answered (delta 20260918d §4.9). */
521
+ export async function handleAnswerMarksGet(
522
+ paseo: DashboardPaseo,
523
+ deps: { homedir?: () => string } = {},
524
+ ): Promise<{ keys: string[]; notices: string[] }> {
525
+ const { keys, notices } = readAnswerMarks(await requireLocation(paseo, deps));
526
+ return { keys, notices };
527
+ }
528
+
529
+ /** `answers.mark` handler: marks one card as answered, or removes the mark. */
530
+ export async function handleAnswerMarkSet(
531
+ input: { key: string; marked: boolean },
532
+ paseo: DashboardPaseo,
533
+ deps: { homedir?: () => string } = {},
534
+ ): Promise<{ keys: string[]; notices: string[] }> {
535
+ const { keys, notices } = writeAnswerMark(await requireLocation(paseo, deps), input.key, input.marked);
536
+ return { keys, notices };
537
+ }
538
+
539
+ /**
540
+ * `workspaces.overview` handler: bead counts and running agents per listed
541
+ * workspace. Read-only.
542
+ *
543
+ * `runningWorkers` is kept exactly as it was — other readers depend on it — and
544
+ * `runningAgents` is added beside it. Owner decision Q25 (delta 20260917e):
545
+ * the Manager thinking and a Reviewer running both count as "this project is
546
+ * busy". Counting Workers alone left the screen still during the parts of a
547
+ * request where the user most wants to see something happening.
548
+ */
549
+ export async function handleWorkspacesOverview(paseo: DashboardPaseo): Promise<{ workspaces: WorkspaceOverview[] }> {
550
+ const listed = (await listedWorkspaces(paseo)) ?? [];
551
+ const agents = await bmAgentsOf(paseo);
552
+ return {
553
+ workspaces: listed
554
+ .filter((entry) => !entry.archived && entry.id !== "")
555
+ .map((entry) => {
556
+ let beads: WorkspaceOverview["beads"] = null;
557
+ if (entry.directory !== null) {
558
+ try {
559
+ const stats = beadStats(entry.directory);
560
+ if (stats.present) {
561
+ beads = { total: stats.total, inProgress: stats.inProgress, blocked: stats.blocked, ready: stats.ready };
562
+ }
563
+ } catch {
564
+ // An unreadable bead store shows as "no beads", never as an error row.
565
+ }
566
+ }
567
+ const running = agents.filter(
568
+ (agent) => agent.workspaceId === entry.id && agent.facts.status === "running",
569
+ );
570
+ const runningOf = (role: AgentFacts["role"]): number =>
571
+ running.filter((agent) => agent.facts.role === role).length;
572
+ const runningAgents = {
573
+ manager: runningOf("manager"),
574
+ worker: runningOf("worker"),
575
+ reviewer: runningOf("reviewer"),
576
+ };
577
+ return { workspaceId: entry.id, beads, runningWorkers: runningAgents.worker, runningAgents };
578
+ }),
579
+ };
580
+ }
581
+
582
+ /** `beads.list` handler. Read-only. */
583
+ export async function handleBeadsList(
584
+ input: { workspaceId: string },
585
+ paseo: DashboardPaseo,
586
+ deps: { homedir?: () => string } = {},
587
+ ): Promise<{ beads: BeadRow[]; stats: BeadStats }> {
588
+ const directory = await workspaceDirectory(paseo, input.workspaceId);
589
+ if (directory === null) {
590
+ return { beads: [], stats: emptyBeadStats(`workspace ${input.workspaceId} is not listed by Paseo`) };
591
+ }
592
+ const rows = listBeadRows(directory);
593
+ const working = rows.filter((row) => row.status === "in_progress").map((row) => row.id);
594
+ if (working.length > 0) {
595
+ // Who is on it comes from the trace store. Without a store (tracing off,
596
+ // nothing recorded yet) the list is still complete, just without names.
597
+ try {
598
+ const location = await requireLocation(paseo, deps);
599
+ const work = beadWorkOf(
600
+ working,
601
+ readRecords(location, input.workspaceId).records,
602
+ await agentFactsOf(paseo, input.workspaceId),
603
+ );
604
+ for (const row of rows) row.work = work.get(row.id) ?? null;
605
+ } catch {
606
+ // Names are an extra; the bead list does not depend on them.
607
+ }
608
+ }
609
+ return { beads: rows, stats: beadStats(directory) };
610
+ }
611
+
612
+ async function requireDirectory(paseo: DashboardPaseo, workspaceId: string): Promise<string> {
613
+ const directory = await workspaceDirectory(paseo, workspaceId);
614
+ if (directory === null) {
615
+ throw new DashboardError("E_BEADS_STORE_UNREADABLE", `workspace ${workspaceId} is not listed by Paseo`);
616
+ }
617
+ return directory;
618
+ }
619
+
620
+ export async function handleBeadsGet(
621
+ input: { workspaceId: string; id: string },
622
+ paseo: DashboardPaseo,
623
+ ): Promise<{ bead: BeadDetail }> {
624
+ return { bead: getBeadDetail(await requireDirectory(paseo, input.workspaceId), input.id) };
625
+ }
626
+
627
+ export async function handleBeadsAction(
628
+ input: { workspaceId: string; id: string; action: BeadAction },
629
+ paseo: DashboardPaseo & BeadActionPaseo,
630
+ ensure: (workspaceId: string) => Promise<{ agentId: string; created: boolean }>,
631
+ ): Promise<{ managerId: string; created: boolean }> {
632
+ // Resolve the bead first: an unknown id must fail before anything is sent.
633
+ const bead = getBeadDetail(await requireDirectory(paseo, input.workspaceId), input.id);
634
+ return runBeadAction({ workspaceId: input.workspaceId, action: input.action, bead }, { paseo, ensure });
635
+ }
636
+
637
+ /** Registers every Dashboard RPC on the server context. */
638
+ export function registerDashboardRpcs(
639
+ server: PluginServerContext,
640
+ deps: { ensureManager?: (workspaceId: string, paseo: unknown) => Promise<{ agentId: string; created: boolean }> } = {},
641
+ ): void {
642
+ // The SDK's `paseo` is structurally a `DashboardPaseo`; one cast, here.
643
+ const sdk = (context: { paseo: unknown }) => context.paseo as DashboardPaseo & BeadActionPaseo;
644
+ server.handle(tracesListRpc, (input, context) => handleTracesList(input, sdk(context)));
645
+ server.handle(tracesGetRpc, (input, context) => handleTracesGet(input, sdk(context)));
646
+ server.handle(tracesDeleteRpc, (input, context) => handleTracesDelete(input, sdk(context)));
647
+ server.handle(tracesReassignRpc, (input, context) => handleTracesReassign(input, sdk(context)));
648
+ server.handle(tracesWorkspacesRpc, (_input, context) => handleTracesWorkspaces(sdk(context)));
649
+ server.handle(beadsStatsRpc, (input, context) => handleBeadsStats(input, sdk(context)));
650
+ server.handle(beadsListRpc, (input, context) => handleBeadsList(input, sdk(context)));
651
+ server.handle(beadsGetRpc, (input, context) => handleBeadsGet(input, sdk(context)));
652
+ server.handle(workspacesOverviewRpc, (_input, context) => handleWorkspacesOverview(sdk(context)));
653
+ server.handle(answersMarksRpc, (_input, context) => handleAnswerMarksGet(sdk(context)));
654
+ server.handle(answersMarkRpc, (input, context) => handleAnswerMarkSet(input, sdk(context)));
655
+ server.handle(agentsStopAllRpc, (input, context) => handleAgentsStopAll(input, context.paseo as StopPaseo));
656
+ const ensureManager = deps.ensureManager;
657
+ if (ensureManager !== undefined) {
658
+ server.handle(beadsActionRpc, (input, context) =>
659
+ handleBeadsAction(input, sdk(context), (workspaceId) => ensureManager(workspaceId, context.paseo)),
660
+ );
661
+ }
662
+ }