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,325 @@
1
+ /**
2
+ * `roles.settings` and `roles.options`: the data of the Roles & models section
3
+ * of the Setup screen (delta 20260921 §4.3.2, REQ-064 a/b). Both only read.
4
+ *
5
+ * - `roles.settings` describes the three roles from ONE `config.get()`. The
6
+ * SDK view is flat (design F12): `config.providers` is `agents.providers`,
7
+ * `config.agentProfiles` is `daemon.agentProfiles`. It hands the client the
8
+ * `revision` that `roles.save-settings` must send back.
9
+ * - `roles.options` lists what the Edit form may offer for one BASE provider,
10
+ * from `providers.listModels`, `listModes`, `listFeatures` and `costOf`.
11
+ *
12
+ * Neither handler throws anything but a coded `DashboardError`: a lookup that
13
+ * fails, times out or answers an `error` becomes an empty list or `unknown`
14
+ * and costs one `console.warn` line starting `[paseo-bm]`. Every lookup is
15
+ * raced against `LOOKUP_TIMEOUT_MS`.
16
+ */
17
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
18
+ import { homedir } from "node:os";
19
+ import { canonicalJson, roleConfigRevision, writeRoleConfig, type ConfigPaseo, type RoleConfigView } from "./config-writer";
20
+ import { fallbackForSettings, handleRolesSaveFallback } from "./fallback-settings";
21
+ import { asRecord, checkRoleChoice, isRoleAlias, modelOptionsOf, nonEmpty, pickableProviders, reasonOf } from "./role-choices";
22
+ import { childFactLine, notifyChildFactChange, type SettingsPaseo } from "./settings-notices";
23
+ import {
24
+ LOOKUP_TIMEOUT_MS,
25
+ TIMED_OUT,
26
+ capabilityOf,
27
+ featuresFor,
28
+ modesFor,
29
+ offersAutoApprove,
30
+ withTimeout,
31
+ type ProviderCapability,
32
+ } from "./role-mode";
33
+ import {
34
+ DashboardError,
35
+ rolesOptionsRpc,
36
+ rolesSaveFallbackRpc,
37
+ rolesSaveSettingsRpc,
38
+ rolesSettingsRpc,
39
+ type RolesSaveSettingsInput,
40
+ type BmRole,
41
+ type RoleModeOption,
42
+ type RoleModelOption,
43
+ type RoleSetting,
44
+ type RolesOptions,
45
+ type RolesSettings,
46
+ } from "../shared/contracts";
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Revision.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * The part of Paseo's config the role settings live in (the SDK's flat view,
54
+ * F12). Wider than the writer's view: a read tolerates malformed entries.
55
+ */
56
+ export interface RoleSettingsConfig {
57
+ providers?: Record<string, unknown> | null;
58
+ agentProfiles?: ReadonlyArray<unknown> | null;
59
+ }
60
+
61
+ /** JSON with object keys sorted at every level (one definition, in config-writer.ts). */
62
+ export { canonicalJson };
63
+
64
+ /**
65
+ * The revision of the role settings (delta 20260921 §4.3.2). THE single
66
+ * definition lives in `config-writer.ts` (`roleConfigRevision`): `roles.settings`
67
+ * hands this value to the client and the writer compares a save's input with
68
+ * the same function, or every save would be refused as a conflict. Never throws.
69
+ */
70
+ export function roleSettingsRevision(config: RoleSettingsConfig | null | undefined): string {
71
+ // The revision only hashes the entries, so malformed ones are fine here.
72
+ return roleConfigRevision((config ?? {}) as RoleConfigView);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Shared helpers.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface RoleSettingsDeps {
80
+ /** Where a failure is reported; one line each. */
81
+ log?: (message: string) => void;
82
+ /** Home directory used as the `cwd` of the features lookup (the daemon requires one). */
83
+ homedir?: () => string;
84
+ }
85
+
86
+ const defaultLog = (message: string): void => console.warn(message);
87
+
88
+ const ROLES: readonly BmRole[] = ["manager", "worker", "reviewer"];
89
+
90
+ const PROVIDER_IDS = { manager: "bm-manager", worker: "bm-worker", reviewer: "bm-reviewer" } as const satisfies Record<
91
+ BmRole,
92
+ RoleSetting["providerId"]
93
+ >;
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // roles.settings
97
+ // ---------------------------------------------------------------------------
98
+
99
+ type ConfigRead = { config: RoleSettingsConfig } | { failure: string };
100
+
101
+ /** ONE `config.get()` under the lookup budget. Never throws. */
102
+ async function readConfig(paseo: unknown): Promise<ConfigRead> {
103
+ const config = (paseo as { config?: { get?: unknown } } | null | undefined)?.config;
104
+ const get = config?.get;
105
+ if (typeof get !== "function") return { failure: "this Paseo host cannot read its configuration" };
106
+ try {
107
+ const result = await withTimeout(get.call(config) as Promise<{ config?: unknown } | null | undefined>);
108
+ if (result === TIMED_OUT) return { failure: `no answer within ${LOOKUP_TIMEOUT_MS} ms` };
109
+ const view = asRecord(result?.config);
110
+ if (view === null) return { failure: "the answer carried no configuration" };
111
+ return { config: view as RoleSettingsConfig };
112
+ } catch (error) {
113
+ return { failure: reasonOf(error) };
114
+ }
115
+ }
116
+
117
+ /** A role the configuration does not describe: every field `null`. */
118
+ function emptySetting(role: BmRole): RoleSetting {
119
+ return {
120
+ role,
121
+ providerId: PROVIDER_IDS[role],
122
+ baseProvider: null,
123
+ label: null,
124
+ model: null,
125
+ thinkingOptionId: null,
126
+ modeId: null,
127
+ featureValues: {},
128
+ capability: "unknown",
129
+ };
130
+ }
131
+
132
+ /** The warning of §4.3.3 / REQ-064: a Worker at its limit stops the Manager too. */
133
+ export function sharedPlanWarning(provider: string): string {
134
+ return `Manager and Worker share the ${provider} plan: if the Worker hits its limit, the Manager stops too.`;
135
+ }
136
+
137
+ /**
138
+ * Handler body of `roles.settings`. Always three roles, in the order manager,
139
+ * worker, reviewer; a role the configuration lacks has `null` fields. The
140
+ * capability is looked up once per distinct base provider.
141
+ *
142
+ * When the configuration cannot be read at all, the three roles come back
143
+ * empty with a warning, and `revision` is that of an empty configuration: a
144
+ * save sent with it is then refused (`E_ROLE_SETTINGS_CONFLICT` against a
145
+ * configuration that has roles) rather than written blind.
146
+ */
147
+ export async function handleRolesSettings(paseo: unknown, deps: RoleSettingsDeps = {}): Promise<RolesSettings> {
148
+ const log = deps.log ?? defaultLog;
149
+ const read = await readConfig(paseo);
150
+ if ("failure" in read) {
151
+ log(`[paseo-bm] could not read the Paseo configuration for Roles & models (${read.failure}).`);
152
+ return {
153
+ revision: roleSettingsRevision({}),
154
+ roles: ROLES.map(emptySetting),
155
+ // No second config read for the install home: the chains show as the defaults.
156
+ fallback: (await fallbackForSettings(paseo, { ...deps, home: null })).fallback,
157
+ warnings: [`paseo-bm could not read the Paseo configuration (${read.failure}); reopen Roles & models to try again.`],
158
+ providers: await pickableProviders(paseo),
159
+ };
160
+ }
161
+
162
+ const { config } = read;
163
+ const providers = asRecord(config.providers) ?? {};
164
+ const profiles = Array.isArray(config.agentProfiles) ? config.agentProfiles : [];
165
+ const capabilities = new Map<string, Promise<ProviderCapability>>();
166
+ const capabilityFor = (provider: string): Promise<ProviderCapability> => {
167
+ let known = capabilities.get(provider);
168
+ if (known === undefined) {
169
+ known = modesFor(paseo, provider, log).then(capabilityOf, () => "unknown" as const);
170
+ capabilities.set(provider, known);
171
+ }
172
+ return known;
173
+ };
174
+
175
+ const roles = await Promise.all(
176
+ ROLES.map(async (role): Promise<RoleSetting> => {
177
+ const id = PROVIDER_IDS[role];
178
+ const entry = asRecord(providers[id]);
179
+ const profile = asRecord(profiles.find((candidate) => asRecord(candidate)?.["id"] === id));
180
+ const baseProvider = nonEmpty(entry?.["extends"]);
181
+ const features = asRecord(profile?.["featureValues"]);
182
+ return {
183
+ role,
184
+ providerId: id,
185
+ baseProvider,
186
+ label: nonEmpty(entry?.["label"]),
187
+ model: nonEmpty(profile?.["model"]),
188
+ thinkingOptionId: nonEmpty(profile?.["thinkingOptionId"]),
189
+ modeId: nonEmpty(profile?.["modeId"]),
190
+ featureValues: features === null ? {} : { ...features },
191
+ capability: baseProvider === null ? "unknown" : await capabilityFor(baseProvider),
192
+ };
193
+ }),
194
+ );
195
+
196
+ const warnings: string[] = [];
197
+ const manager = roles.find((setting) => setting.role === "manager")?.baseProvider ?? null;
198
+ const worker = roles.find((setting) => setting.role === "worker")?.baseProvider ?? null;
199
+ if (manager !== null && manager === worker) warnings.push(sharedPlanWarning(manager));
200
+
201
+ const chains = await fallbackForSettings(paseo, deps);
202
+ warnings.push(...chains.warnings);
203
+ return { revision: roleSettingsRevision(config), roles, fallback: chains.fallback, warnings, providers: await pickableProviders(paseo) };
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // roles.options
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Handler body of `roles.options`. `provider` must be a base provider: a
212
+ * `bm-*` alias is refused with `E_ROLE_SETTINGS_INVALID`. Models and modes
213
+ * are read together; the features are read only for an `untiered` provider
214
+ * (§4.2.1), with the home directory as the draft `cwd` the daemon requires.
215
+ */
216
+ export async function handleRolesOptions(
217
+ input: { provider: string },
218
+ paseo: unknown,
219
+ deps: RoleSettingsDeps = {},
220
+ ): Promise<RolesOptions> {
221
+ const provider = typeof input?.provider === "string" ? input.provider : "";
222
+ if (provider.trim() === "") {
223
+ throw new DashboardError("E_ROLE_SETTINGS_INVALID", "name the base provider to list the options of");
224
+ }
225
+ if (isRoleAlias(provider)) {
226
+ throw new DashboardError(
227
+ "E_ROLE_SETTINGS_INVALID",
228
+ `"${provider.slice(0, 200)}" is a paseo-bm role alias, not a base provider; name the provider it extends`,
229
+ );
230
+ }
231
+ const log = deps.log ?? defaultLog;
232
+
233
+ const [models, listedModes] = await Promise.all([modelOptionsOf(paseo, provider, log), modesFor(paseo, provider, log)]);
234
+ const capability = capabilityOf(listedModes);
235
+ const modes: RoleModeOption[] = [];
236
+ const modeIds = new Set<string>();
237
+ for (const mode of listedModes ?? []) {
238
+ const id = nonEmpty(mode?.id);
239
+ if (id === null || modeIds.has(id)) continue;
240
+ modeIds.add(id);
241
+ modes.push({ id, label: nonEmpty(mode.label) ?? id, colorTier: nonEmpty(mode.colorTier) });
242
+ }
243
+
244
+ let autoAccept = false;
245
+ if (capability === "untiered") {
246
+ let cwd: string | undefined;
247
+ try {
248
+ cwd = (deps.homedir ?? homedir)();
249
+ } catch {
250
+ cwd = undefined;
251
+ }
252
+ autoAccept = offersAutoApprove(await featuresFor(paseo, provider, cwd, log));
253
+ }
254
+
255
+ return { provider, capability, models, modes, autoAccept };
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // roles.save-settings (delta 20260921 §4.3.3–§4.3.4, REQ-064, REQ-063 h)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /** The warnings of a saved role (REQ-063 h and the §4.3.3 notes); never blocking. */
263
+ function saveWarnings(input: RolesSaveSettingsInput, capability: ProviderCapability, cost: RoleModelOption["cost"]): string[] {
264
+ const warnings: string[] = [];
265
+ const pi = input.baseProvider === "pi";
266
+ if (pi && (input.role === "manager" || input.role === "worker")) {
267
+ warnings.push("Pi needs pi-mcp-adapter to give this role Paseo tools.");
268
+ }
269
+ if (input.role === "reviewer" && (pi || capability === "none")) {
270
+ warnings.push("Pi does not ask before running tools; the Reviewer's read-only rule is only in its instructions.");
271
+ }
272
+ if (input.role === "reviewer" && capability === "untiered" && input.modeId === null) {
273
+ warnings.push("The Reviewer runs with your OpenCode agent's permissions; paseo-bm never auto-approves for it.");
274
+ }
275
+ if (cost !== null) {
276
+ warnings.push(`Priced at ~$${cost.inputUsdPerMTok} / $${cost.outputUsdPerMTok} per 1M tokens.`);
277
+ }
278
+ return warnings;
279
+ }
280
+
281
+ /**
282
+ * Handler body of `roles.save-settings`. Every check of §4.3.3 runs before any
283
+ * write and fails with `E_ROLE_SETTINGS_INVALID`: the base provider is
284
+ * available and is not a `bm-*` alias; the model is one Paseo lists for it;
285
+ * the thinking level is one of that model's; the mode is one the provider
286
+ * lists, and never `dangerous` / `planning` for the Reviewer on a tiered
287
+ * provider. Then one write through `config-writer` (revision check included),
288
+ * and the saved role read back. Warnings never block.
289
+ */
290
+ export async function handleRolesSaveSettings(
291
+ input: RolesSaveSettingsInput,
292
+ paseo: unknown,
293
+ deps: RoleSettingsDeps = {},
294
+ ): Promise<{ revision: string; role: RoleSetting; warnings: string[]; notified: number }> {
295
+ const log = deps.log ?? defaultLog;
296
+ const { model, capability } = await checkRoleChoice(input.role, input, paseo, log);
297
+
298
+ const id = PROVIDER_IDS[input.role];
299
+ // The creator's child line before the write, to tell live agents a change (§4.3.5).
300
+ const lineBefore = await childFactLine(input.role, paseo);
301
+ await writeRoleConfig(paseo as ConfigPaseo, {
302
+ expectedRevision: input.revision,
303
+ providers: { [id]: { extends: input.baseProvider } },
304
+ profiles: { [id]: { model: input.model, thinkingOptionId: input.thinkingOptionId, modeId: input.modeId } },
305
+ });
306
+
307
+ const settings = await handleRolesSettings(paseo, deps);
308
+ const role = settings.roles.find((entry) => entry.role === input.role) ?? emptySetting(input.role);
309
+ const shared = settings.warnings.filter((warning) => warning.startsWith("Manager and Worker share"));
310
+ const lineAfter = await childFactLine(input.role, paseo);
311
+ const notified = await notifyChildFactChange(input.role, lineBefore, lineAfter, paseo as SettingsPaseo, { log });
312
+ return { revision: settings.revision, role, warnings: [...shared, ...saveWarnings(input, capability, model.cost)], notified };
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Registration.
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /** Registers `roles.settings`, `roles.options`, `roles.save-settings` and `roles.save-fallback` (§4.4.3). */
320
+ export function registerRoleSettingsRpcs(server: PluginServerContext): void {
321
+ server.handle(rolesSettingsRpc, (_input, context) => handleRolesSettings(context.paseo));
322
+ server.handle(rolesOptionsRpc, (input, context) => handleRolesOptions(input, context.paseo));
323
+ server.handle(rolesSaveSettingsRpc, (input, context) => handleRolesSaveSettings(input, context.paseo));
324
+ server.handle(rolesSaveFallbackRpc, (input, context) => handleRolesSaveFallback(input, context.paseo));
325
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `roles.describe` on the daemon side (design §5, REQ-032d; errata bm-dnc).
3
+ *
4
+ * The answer comes from the configuration Paseo has in effect right now, read
5
+ * through the plugin SDK's `paseo.config.get()` — not from `install.json`. The
6
+ * server entry runs as a CommonJS bundle in a worker that knows nothing about
7
+ * where the payload or the install home live, so reading a file is not an
8
+ * option; the daemon configuration is also exactly what Paseo uses when it
9
+ * starts a role, so it works for any install home.
10
+ *
11
+ * SDK facts this relies on (checked, not guessed):
12
+ * - `PaseoApi.config.get(): Promise<{ requestId; config: MutableDaemonConfig }>`
13
+ * (@getpaseo/client 0.8.0 `dist/index.d.ts`, `PaseoConfigActions`).
14
+ * - `MutableDaemonConfig.providers` is the daemon's view of `agents.providers`
15
+ * (a loose record: `paseoTools?: { enabled?: boolean }`, plus pass-through
16
+ * keys such as `extends`); `MutableDaemonConfig.agentProfiles` is
17
+ * `daemon.agentProfiles` (`{ id, provider, model?, … }[]`)
18
+ * (@getpaseo/protocol 0.8.0 `dist/messages.d.ts`, `MutableDaemonConfigSchema`).
19
+ * - The Paseo 0.8 daemon fills `providers` from `agents.providers` parsed with
20
+ * `ProviderOverrideSchema`, which declares `extends`.
21
+ *
22
+ * Read-only: nothing is patched.
23
+ */
24
+ import type { BmRole, RoleDescriptor } from "../shared/contracts";
25
+
26
+ const ROLE_ORDER: readonly BmRole[] = ["manager", "worker", "reviewer"];
27
+
28
+ /** Derived provider id and agent profile id of a role, as the installer registers them (ADR-006). */
29
+ export function roleConfigId(role: BmRole): string {
30
+ return `bm-${role}`;
31
+ }
32
+
33
+ /** Name of a role's instructions inside the payload (embedded in the bundle for the Manager). */
34
+ export function roleInstructionsName(role: BmRole): string {
35
+ return `roles/${role}.md`;
36
+ }
37
+
38
+ /** Minimal SDK view `roles.describe` needs. `PaseoApi` is structurally assignable. */
39
+ export interface RolesConfigPaseo {
40
+ config: {
41
+ get(): Promise<{
42
+ config: {
43
+ providers?: Record<string, unknown>;
44
+ agentProfiles?: ReadonlyArray<{ id: string; provider: string; model?: string }>;
45
+ };
46
+ }>;
47
+ };
48
+ }
49
+
50
+ export interface DescribeRolesDeps {
51
+ paseo: RolesConfigPaseo;
52
+ }
53
+
54
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value)
56
+ ? (value as Record<string, unknown>)
57
+ : undefined;
58
+ }
59
+
60
+ /**
61
+ * Handler body of `roles.describe`.
62
+ *
63
+ * For each role, in the order manager, worker, reviewer:
64
+ * - `provider` is `extends` of the derived provider `agents.providers.bm-<role>`
65
+ * (the user's own tool), or `""` when the entry or the key is missing;
66
+ * - `model` is `model` of the agent profile `bm-<role>`, or `""`;
67
+ * - `paseoTools` is true only when `paseoTools.enabled === true` on the derived
68
+ * provider (the shape Paseo stores; an absent key means not granted);
69
+ * - `instructionsPath` is the instructions name, e.g. `roles/manager.md`.
70
+ *
71
+ * A role with neither a derived provider nor a profile is not registered and
72
+ * is left out, so a daemon without paseo-bm roles yields `{ roles: [] }`.
73
+ */
74
+ export async function describeRoles(deps: DescribeRolesDeps): Promise<{ roles: RoleDescriptor[] }> {
75
+ const { config } = await deps.paseo.config.get();
76
+ const providers = asRecord(config.providers) ?? {};
77
+ const profiles = config.agentProfiles ?? [];
78
+
79
+ const roles: RoleDescriptor[] = [];
80
+ for (const role of ROLE_ORDER) {
81
+ const id = roleConfigId(role);
82
+ const provider = asRecord(providers[id]);
83
+ const profile = profiles.find((entry) => entry.id === id);
84
+ if (!provider && !profile) continue;
85
+
86
+ const base = provider?.["extends"];
87
+ roles.push({
88
+ role,
89
+ provider: typeof base === "string" ? base : "",
90
+ model: typeof profile?.model === "string" ? profile.model : "",
91
+ paseoTools: asRecord(provider?.["paseoTools"])?.["enabled"] === true,
92
+ instructionsPath: roleInstructionsName(role),
93
+ });
94
+ }
95
+ return { roles };
96
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Tells live agents that a child's start mode changed (delta 20260921 §4.3.5,
3
+ * REQ-064 d).
4
+ *
5
+ * The daemon validates an explicit mode BEFORE the `agent.create` hook runs
6
+ * (design F5): a Manager created before the user moved the Worker to another
7
+ * provider would pass the old `## Runtime facts` mode and be refused. So after
8
+ * a save that changes the Runtime-facts line of a child role, every live agent
9
+ * that creates that child gets the new line as a `BM-SETTINGS` notice, through
10
+ * the notice queue (it may be running, design F13):
11
+ *
12
+ * - the Worker line changed → every live Manager, on every workspace;
13
+ * - the Reviewer line changed → every live Worker (fallback Workers too).
14
+ *
15
+ * The queue lives in memory; lost on a plugin reload, the agent falls back to
16
+ * today's behaviour (Paseo refuses the creation, the agent sends `blocked`).
17
+ */
18
+ import { listAllAgents, roleOfAgent, type BmRole } from "./agent-role";
19
+ import { SETTINGS_NOTICE_MARKER } from "./notices";
20
+ import { enqueue as defaultEnqueue, type NoticeOutcome, type NoticePaseo } from "./notice-queue";
21
+ import { RUNTIME_FACTS_HEADING, runtimeFactsOf, runtimeFactsText } from "./role-extras";
22
+
23
+ /** Which role's Runtime facts carry the child mode of each saved role. */
24
+ const CREATOR_OF: Readonly<Partial<Record<BmRole, "manager" | "worker">>> = { worker: "manager", reviewer: "worker" };
25
+
26
+ /**
27
+ * The child line the creator of `savedRole` is told now (for example
28
+ * ``Worker mode: `bypassPermissions` — …``), `""` when there is none, or
29
+ * `null` when `savedRole` is not a child role (the Manager). Never throws.
30
+ */
31
+ export async function childFactLine(savedRole: BmRole, paseo: unknown, cwd?: string): Promise<string | null> {
32
+ const creator = CREATOR_OF[savedRole];
33
+ if (creator === undefined) return null;
34
+ try {
35
+ const text = runtimeFactsText(creator, await runtimeFactsOf(creator, paseo, cwd, () => {}));
36
+ const prefix = `${RUNTIME_FACTS_HEADING}\n\n`;
37
+ return text.startsWith(prefix) ? text.slice(prefix.length) : "";
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ /** The notice, word for word (agent-facing, so English). */
44
+ export function settingsNotice(line: string): string {
45
+ return [
46
+ `${SETTINGS_NOTICE_MARKER} The user changed the paseo-bm role settings. This replaces the matching line under "${RUNTIME_FACTS_HEADING}":`,
47
+ line,
48
+ "Do not reply to this message; carry on with what you were doing.",
49
+ ].join("\n");
50
+ }
51
+
52
+ /**
53
+ * The line a Worker gets when its Manager was replaced after a plugin fallback
54
+ * (delta 20260921 §4.5.2, step 5), word for word.
55
+ */
56
+ export function managerIdLine(managerId: string): string {
57
+ return `Manager agent id: \`${managerId}\` — send every BM-REPORT to this agent from now on.`;
58
+ }
59
+
60
+ /** The `BM-SETTINGS` notice that carries `managerIdLine` (agent-facing, so English). */
61
+ export function managerIdNotice(managerId: string): string {
62
+ return [
63
+ `${SETTINGS_NOTICE_MARKER} The Beads Manager you report to was replaced. This replaces the Manager agent id you were given:`,
64
+ managerIdLine(managerId),
65
+ "Do not reply to this message; carry on with what you were doing.",
66
+ ].join("\n");
67
+ }
68
+
69
+ /** The SDK slice this module uses; `PaseoApi` is structurally assignable. */
70
+ export interface SettingsPaseo extends NoticePaseo {
71
+ agents: NoticePaseo["agents"] & {
72
+ list(options: {
73
+ filter: { includeArchived: boolean };
74
+ page: { limit: number; cursor?: string };
75
+ }): Promise<{
76
+ entries: Array<{ agent: { id: string; provider?: string; labels?: Record<string, string> | null; archivedAt?: string | null } }>;
77
+ pageInfo?: { nextCursor: string | null; hasMore: boolean };
78
+ }>;
79
+ };
80
+ }
81
+
82
+ export interface SettingsNoticeDeps {
83
+ enqueue?: (targetId: string, kind: string, text: string, paseo?: NoticePaseo) => Promise<NoticeOutcome>;
84
+ log?: (message: string) => void;
85
+ }
86
+
87
+ /**
88
+ * Sends `line` to every live agent that creates `savedRole`'s agents, when it
89
+ * differs from `before` and is not empty. Returns how many were sent or queued.
90
+ * Never throws.
91
+ */
92
+ export async function notifyChildFactChange(
93
+ savedRole: BmRole,
94
+ before: string | null,
95
+ after: string | null,
96
+ paseo: SettingsPaseo,
97
+ deps: SettingsNoticeDeps = {},
98
+ ): Promise<number> {
99
+ const creator = CREATOR_OF[savedRole];
100
+ if (creator === undefined || after === null || after === "" || after === before) return 0;
101
+ const log = deps.log ?? ((message: string) => console.warn(message));
102
+ try {
103
+ const agents = await listAllAgents((options) => paseo.agents.list(options), { includeArchived: false });
104
+ const targets = agents.filter((agent) => !agent.archivedAt && roleOfAgent(agent)?.role === creator);
105
+ const send = deps.enqueue ?? defaultEnqueue;
106
+ const outcomes = await Promise.all(targets.map((agent) => send(agent.id, SETTINGS_NOTICE_MARKER, settingsNotice(after), paseo)));
107
+ return outcomes.filter((outcome) => outcome === "sent" || outcome === "queued").length;
108
+ } catch (error) {
109
+ log(`[paseo-bm] telling live agents about the new ${savedRole} mode failed: ${error instanceof Error ? error.message : String(error)}`);
110
+ return 0;
111
+ }
112
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * RPCs of the Setup screen (delta 20260916-setup-screen).
3
+ */
4
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
5
+ import { join } from "node:path";
6
+ import {
7
+ BASE_INSTRUCTIONS,
8
+ MAX_EXTRA_CHARS,
9
+ ROLE_EXTRAS_FILE,
10
+ fullInstructions,
11
+ installHomeOf,
12
+ readRoleExtras,
13
+ saveRoleExtra,
14
+ type Role,
15
+ } from "./role-extras";
16
+ import { skillsStatus, type SkillDeps } from "./setup-skills";
17
+ import { LATEST_KNOWN, installTool, toolsStatus, type ToolDeps } from "./setup-tools";
18
+ import { toolsSeen } from "./tools-check";
19
+ import {
20
+ DashboardError,
21
+ rolesInstructionsRpc,
22
+ rolesSaveExtraRpc,
23
+ setupInstallToolRpc,
24
+ setupStatusRpc,
25
+ type SetupStatus,
26
+ } from "../shared/contracts";
27
+
28
+ export interface SetupDeps extends ToolDeps, SkillDeps {}
29
+
30
+ async function requireHome(paseo: unknown, deps: SetupDeps): Promise<string> {
31
+ const home = await installHomeOf(paseo, deps);
32
+ if (home === null) throw new DashboardError("E_ROLE_EXTRA_INVALID", "cannot save: the paseo-bm install home cannot be found");
33
+ return home;
34
+ }
35
+
36
+ export async function handleSetupStatus(paseo: unknown, deps: SetupDeps = {}): Promise<SetupStatus> {
37
+ const home = await installHomeOf(paseo, deps);
38
+ const extras = home === null ? { manager: "", worker: "", reviewer: "" } : readRoleExtras(home);
39
+ return {
40
+ tools: await toolsStatus(deps),
41
+ latestCheckedOn: LATEST_KNOWN.checkedOn,
42
+ skills: skillsStatus(deps),
43
+ extras: { manager: extras.manager.length, worker: extras.worker.length, reviewer: extras.reviewer.length },
44
+ paseoTools: toolsSeen(),
45
+ };
46
+ }
47
+
48
+ export async function handleRolesInstructions(input: { role: Role }, paseo: unknown, deps: SetupDeps = {}) {
49
+ const home = await installHomeOf(paseo, deps);
50
+ const extra = home === null ? "" : readRoleExtras(home)[input.role];
51
+ return {
52
+ base: BASE_INSTRUCTIONS[input.role],
53
+ extra,
54
+ full: fullInstructions(input.role, extra),
55
+ path: home === null ? null : join(home, ROLE_EXTRAS_FILE),
56
+ maxChars: MAX_EXTRA_CHARS,
57
+ };
58
+ }
59
+
60
+ export async function handleRolesSaveExtra(input: { role: Role; text: string }, paseo: unknown, deps: SetupDeps = {}) {
61
+ const roles = saveRoleExtra(await requireHome(paseo, deps), input.role, input.text);
62
+ return { extra: roles[input.role], full: fullInstructions(input.role, roles[input.role]) };
63
+ }
64
+
65
+ export function registerSetupRpcs(server: PluginServerContext): void {
66
+ server.handle(setupStatusRpc, (_input, context) => handleSetupStatus(context.paseo));
67
+ server.handle(setupInstallToolRpc, (input) => installTool(input.tool));
68
+ server.handle(rolesInstructionsRpc, (input, context) => handleRolesInstructions(input, context.paseo));
69
+ server.handle(rolesSaveExtraRpc, (input, context) => handleRolesSaveExtra(input, context.paseo));
70
+ }