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,270 @@
1
+ /**
2
+ * Additional instructions the user writes for each role (delta
3
+ * 20260916-setup-screen §3.2).
4
+ *
5
+ * Paseo's plugin settings cannot be read on the server, and the instructions
6
+ * are applied on the server (agent.create hook, manager.ensure), so they live
7
+ * in the install home as the user's own file, next to `traces/`: no hash in
8
+ * `install.json`, never touched by an update or `--prune`.
9
+ *
10
+ * They are only ever appended after the base instructions, under a heading
11
+ * that says they cannot override a RULES item.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { z } from "zod";
17
+ import { resolveInstallHome, TRACES_DIR_NAME, type InstallHomePaseo } from "./install-home";
18
+ import { MANAGER_INSTRUCTIONS } from "./manager-instructions";
19
+ import { REVIEWER_INSTRUCTIONS } from "./reviewer-instructions";
20
+ import { WORKER_INSTRUCTIONS } from "./worker-instructions";
21
+ import { writeStoreFileAtomically } from "./trace-store";
22
+ import { TIMED_OUT, capabilityOf, chooseModeId, lastModesOf, modesFor, profileModeOf, runPostureOf, withTimeout, type ProviderMode } from "./role-mode";
23
+ import { DashboardError } from "../shared/contracts";
24
+
25
+ export type Role = "manager" | "worker" | "reviewer";
26
+
27
+ export const ROLE_EXTRAS_FILE = "role-extras.json";
28
+ export const MAX_EXTRA_CHARS = 8000;
29
+
30
+ export const BASE_INSTRUCTIONS: Readonly<Record<Role, string>> = {
31
+ manager: MANAGER_INSTRUCTIONS,
32
+ worker: WORKER_INSTRUCTIONS,
33
+ reviewer: REVIEWER_INSTRUCTIONS,
34
+ };
35
+
36
+ /** Placed between the base and the user's text. Agent-facing, so English. */
37
+ export const EXTRA_HEADING = [
38
+ "## Additional instructions from the user",
39
+ "",
40
+ "These add to the rules above and never override a RULES item.",
41
+ ].join("\n");
42
+
43
+ const extrasSchema = z.object({
44
+ version: z.literal(1),
45
+ roles: z.object({
46
+ manager: z.string().max(MAX_EXTRA_CHARS).default(""),
47
+ worker: z.string().max(MAX_EXTRA_CHARS).default(""),
48
+ reviewer: z.string().max(MAX_EXTRA_CHARS).default(""),
49
+ }),
50
+ });
51
+
52
+ export type RoleExtras = Record<Role, string>;
53
+
54
+ const EMPTY: RoleExtras = { manager: "", worker: "", reviewer: "" };
55
+
56
+ /**
57
+ * Facts the plugin resolves when it builds a role's instructions (delta
58
+ * 20260917c §4.6, owner decision Q22, errata 2026-09-18). Each is the mode an
59
+ * agent must pass when it creates its child, because Paseo refuses that
60
+ * creation without one before any hook runs (K10): the Manager passes the
61
+ * Worker mode, and the Worker — refused as `bypassPermissions` too — passes
62
+ * the Reviewer mode.
63
+ */
64
+ export interface RuntimeFacts {
65
+ workerModeId?: string | null;
66
+ reviewerModeId?: string | null;
67
+ /** The Worker's provider has no modes at all (Pi): the Manager must pass none (delta 20260921 §4.2.3). */
68
+ workerModeNone?: boolean;
69
+ /** The Reviewer's provider has no modes at all (Pi): the Worker must pass none. */
70
+ reviewerModeNone?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Reviewer mode the Worker is told when no mode list could ever be read (owner
75
+ * decision Q9 a). Only for a `bm-reviewer` whose base provider is one of
76
+ * `REVIEWER_FALLBACK_PROVIDERS`, or cannot be read at all: another provider
77
+ * does not list `auto`, and Paseo would refuse the creation on it (delta
78
+ * 20260921 §4.2.2, F4).
79
+ */
80
+ export const REVIEWER_FALLBACK_MODE = "auto";
81
+
82
+ /** Base providers known to list `auto` (proposal §1.1). */
83
+ export const REVIEWER_FALLBACK_PROVIDERS: readonly string[] = ["claude", "codex"];
84
+
85
+ /** Agent-facing, so English. `roles/manager.md` and `roles/worker.md` point at this heading. */
86
+ export const RUNTIME_FACTS_HEADING = "## Runtime facts";
87
+
88
+ /** The Runtime facts section for a role, or "" when there is nothing to state. */
89
+ export function runtimeFactsText(role: Role, facts: RuntimeFacts = {}): string {
90
+ const trimmed = (value: string | null | undefined) => (typeof value === "string" ? value.trim() : "");
91
+ const child = role === "manager" ? "Worker" : role === "worker" ? "Reviewer" : null;
92
+ if (child === null) return "";
93
+ const none = role === "manager" ? facts.workerModeNone === true : facts.reviewerModeNone === true;
94
+ if (none) {
95
+ return `${RUNTIME_FACTS_HEADING}\n\n${child} mode: none — do not pass \`settings.modeId\` when you create a ${child}; Paseo sets it.`;
96
+ }
97
+ const mode = role === "manager" ? trimmed(facts.workerModeId) : trimmed(facts.reviewerModeId);
98
+ if (mode === "") return "";
99
+ return `${RUNTIME_FACTS_HEADING}\n\n${child} mode: \`${mode}\` — pass it as \`settings.modeId\` when you create a ${child}.`;
100
+ }
101
+
102
+ /**
103
+ * Base instructions, then the Runtime facts, then the user's text. With
104
+ * neither facts nor text this is the base, byte for byte.
105
+ */
106
+ export function fullInstructions(role: Role, extra: string, facts: RuntimeFacts = {}): string {
107
+ const text = extra.trim();
108
+ const base = BASE_INSTRUCTIONS[role];
109
+ const factsText = runtimeFactsText(role, facts);
110
+ if (text === "" && factsText === "") return base;
111
+ let out = base.trimEnd();
112
+ if (factsText !== "") out += `\n\n${factsText}`;
113
+ if (text !== "") out += `\n\n---\n\n${EXTRA_HEADING}\n\n${text}`;
114
+ return `${out}\n`;
115
+ }
116
+
117
+ /**
118
+ * The Runtime facts that apply to a role now. For the Manager: the Worker mode
119
+ * the hook's own rule picks from `listModes("bm-worker")`; for the Worker: the
120
+ * Reviewer mode it picks from `listModes("bm-reviewer")`. So what the creator
121
+ * passes and what the hook would choose are the same value. `{}` when it cannot
122
+ * be read — the failure is logged, and the creation attempt then fails loudly
123
+ * with Paseo's list of modes rather than silently.
124
+ */
125
+ export async function runtimeFactsOf(
126
+ role: Role,
127
+ paseo: unknown,
128
+ cwd?: string,
129
+ log: (message: string) => void = (message) => console.warn(message),
130
+ ): Promise<RuntimeFacts> {
131
+ if (role === "reviewer") return {};
132
+ try {
133
+ if (role === "worker") {
134
+ const profileModeId = await profileModeOf(paseo, "bm-reviewer");
135
+ const modes = await modesFor(paseo, "bm-reviewer", undefined, cwd);
136
+ // Delta 20260921 §4.2.2–§4.2.3: the value the hook's posture rule gives
137
+ // the Reviewer on an untiered provider, or "none" for one without modes.
138
+ const byCapability = childModeByCapability("reviewer", modes, profileModeId);
139
+ if (byCapability !== undefined) {
140
+ return byCapability === null ? { reviewerModeNone: true } : { reviewerModeId: byCapability };
141
+ }
142
+ if (modes === null) {
143
+ const base = await baseProviderOf(paseo, "bm-reviewer");
144
+ if (base !== null && !REVIEWER_FALLBACK_PROVIDERS.includes(base)) {
145
+ // `auto` is Claude's and Codex's; told to a Worker whose Reviewer runs
146
+ // elsewhere, it would only make Paseo refuse the creation (F4).
147
+ const last = lastModesOf("bm-reviewer");
148
+ const fromLast = last === null ? undefined : childModeByCapability("reviewer", last.modes, profileModeId);
149
+ if (typeof fromLast === "string") {
150
+ log(`[paseo-bm] could not read the modes of bm-reviewer; the Worker is told to pass the Reviewer mode "${fromLast}" (last list read at ${last!.at}).`);
151
+ return { reviewerModeId: fromLast };
152
+ }
153
+ log(`[paseo-bm] could not read the modes of bm-reviewer (base provider ${base}); the Worker is told no Reviewer mode.`);
154
+ return {};
155
+ }
156
+ // Unlike the Manager's case, the profile's own mode is NOT passed blind:
157
+ // its tier is unknown, and a Reviewer must never run in a dangerous one.
158
+ // The fallback is the last list read in this run, through the same
159
+ // rule, else `auto` — the rule's first choice, listed by Claude and
160
+ // Codex; a provider without it makes Paseo refuse the creation loudly
161
+ // (delta 20260918g §4.9, owner decisions Q4 a and Q9 a).
162
+ const last = lastModesOf("bm-reviewer");
163
+ const fromLast = last === null ? undefined : chooseModeId("reviewer", last.modes, undefined, profileModeId);
164
+ const reviewerModeId = fromLast ?? REVIEWER_FALLBACK_MODE;
165
+ const source = fromLast === undefined ? "static fallback" : `last list read at ${last!.at}`;
166
+ log(`[paseo-bm] could not read the modes of bm-reviewer; the Worker is told to pass the fallback Reviewer mode "${reviewerModeId}" (${source}).`);
167
+ return { reviewerModeId };
168
+ }
169
+ const reviewerModeId = chooseModeId("reviewer", modes, undefined, profileModeId);
170
+ return reviewerModeId === undefined ? {} : { reviewerModeId };
171
+ }
172
+ const profileModeId = await profileModeOf(paseo, "bm-worker");
173
+ const modes = await modesFor(paseo, "bm-worker", undefined, cwd);
174
+ const byCapability = childModeByCapability("worker", modes, profileModeId);
175
+ if (byCapability !== undefined) {
176
+ return byCapability === null ? { workerModeNone: true } : { workerModeId: byCapability };
177
+ }
178
+ if (modes === null) {
179
+ // A mode the owner set by hand on the profile is still worth passing:
180
+ // Paseo validates it and reports its own error if it is wrong.
181
+ return profileModeId === null ? {} : { workerModeId: profileModeId };
182
+ }
183
+ const workerModeId = chooseModeId("worker", modes, undefined, profileModeId);
184
+ return workerModeId === undefined ? {} : { workerModeId };
185
+ } catch (error) {
186
+ log(`[paseo-bm] reading the Runtime facts of ${role} failed: ${error instanceof Error ? error.message : String(error)}`);
187
+ return {};
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The child mode on an `untiered` provider (the posture rule's mode: the
193
+ * profile's if listed, else the first listed), `null` for a provider with no
194
+ * modes, and `undefined` for `tiered` / `unknown`, whose rules stay as they were.
195
+ */
196
+ function childModeByCapability(
197
+ role: "worker" | "reviewer",
198
+ modes: readonly ProviderMode[] | null,
199
+ profileModeId: string | null,
200
+ ): string | null | undefined {
201
+ const capability = capabilityOf(modes);
202
+ if (capability === "none") return null;
203
+ if (capability !== "untiered") return undefined;
204
+ return runPostureOf(role, "untiered", modes ?? [], null, profileModeId)?.modeId;
205
+ }
206
+
207
+ /** The `extends` of a paseo-bm provider alias, or `null` when it cannot be read. Never throws. */
208
+ async function baseProviderOf(paseo: unknown, alias: string): Promise<string | null> {
209
+ const get = (paseo as { config?: { get?: unknown } } | null | undefined)?.config?.get;
210
+ if (typeof get !== "function") return null;
211
+ try {
212
+ const result = await withTimeout(
213
+ get.call((paseo as { config: unknown }).config) as Promise<{ config?: { providers?: Record<string, { extends?: unknown } | undefined> } }>,
214
+ );
215
+ if (result === TIMED_OUT) return null;
216
+ const base = result?.config?.providers?.[alias]?.extends;
217
+ return typeof base === "string" && base.trim() !== "" ? base : null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /** The extras in `home`; empty when the file is missing or unreadable. Never throws. */
224
+ export function readRoleExtras(home: string): RoleExtras {
225
+ try {
226
+ const parsed = extrasSchema.safeParse(JSON.parse(readFileSync(join(home, ROLE_EXTRAS_FILE), "utf8")));
227
+ return parsed.success ? parsed.data.roles : { ...EMPTY };
228
+ } catch {
229
+ return { ...EMPTY };
230
+ }
231
+ }
232
+
233
+ /** Replaces one role's text. Atomic, 0600, and refuses symlinks inside the home. */
234
+ export function saveRoleExtra(home: string, role: Role, text: string): RoleExtras {
235
+ if (text.length > MAX_EXTRA_CHARS) {
236
+ throw new DashboardError("E_ROLE_EXTRA_INVALID", `at most ${MAX_EXTRA_CHARS} characters, got ${text.length}`);
237
+ }
238
+ const roles = { ...readRoleExtras(home), [role]: text };
239
+ writeStoreFileAtomically(
240
+ { tracesDir: join(home, TRACES_DIR_NAME) },
241
+ join(home, ROLE_EXTRAS_FILE),
242
+ `${JSON.stringify({ version: 1, roles }, null, 2)}\n`,
243
+ );
244
+ return roles;
245
+ }
246
+
247
+ /** The install home, or null when paseo-bm's home cannot be confirmed. */
248
+ export async function installHomeOf(paseo: unknown, deps: { homedir?: () => string } = {}): Promise<string | null> {
249
+ try {
250
+ const resolution = await resolveInstallHome({
251
+ paseo: paseo as InstallHomePaseo,
252
+ fs: { readFileSync: (path, encoding) => readFileSync(path, encoding) },
253
+ homedir: deps.homedir ?? homedir,
254
+ });
255
+ return resolution.home;
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ /** Full instructions for a role as they apply now, falling back to the base. */
262
+ export async function currentInstructions(
263
+ role: Role,
264
+ paseo: unknown,
265
+ deps: { homedir?: () => string; cwd?: string } = {},
266
+ ): Promise<string> {
267
+ const home = await installHomeOf(paseo, deps);
268
+ const facts = await runtimeFactsOf(role, paseo, deps.cwd);
269
+ return fullInstructions(role, home === null ? "" : readRoleExtras(home)[role], facts);
270
+ }
@@ -0,0 +1,347 @@
1
+ import type { PluginBeforeRequests, PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { roleOfProvider } from "./agent-role";
3
+ import { providerId } from "./provider-id";
4
+ import {
5
+ LOOKUP_TIMEOUT_MS,
6
+ ROLE_GETS_MODE,
7
+ TIMED_OUT,
8
+ capabilityOf,
9
+ chooseModeId,
10
+ featuresFor,
11
+ modesFor,
12
+ profileOf,
13
+ runPostureOf,
14
+ withTimeout,
15
+ type ProviderCapability,
16
+ type ProviderFeature,
17
+ type ProviderMode,
18
+ type RoleProfile,
19
+ } from "./role-mode";
20
+ import {
21
+ BASE_INSTRUCTIONS,
22
+ fullInstructions,
23
+ installHomeOf,
24
+ readRoleExtras,
25
+ runtimeFactsOf,
26
+ type RoleExtras,
27
+ type RuntimeFacts,
28
+ } from "./role-extras";
29
+
30
+ /**
31
+ * Role instructions injected into every paseo-bm agent at creation (bm-hld).
32
+ *
33
+ * Paseo's `create_agent` tool has no system-prompt parameter, so a Worker the
34
+ * Manager creates, or a Reviewer the Worker creates, would otherwise start
35
+ * without its role contract. The daemon runs `before("agent.create")` hooks for
36
+ * every agent creation, whoever asks for it, so the plugin sets the prompt
37
+ * there, keyed by the provider id.
38
+ *
39
+ * The same hook sets the start mode of Workers and Reviewers (delta 20260917c
40
+ * §4.6): what the role files used to teach in a paragraph each is one lookup
41
+ * here, and it cannot be got wrong by an agent.
42
+ *
43
+ * Provider ids map to roles through `roleOfProvider` (agent-role.ts), the
44
+ * same rule every lookup uses to tell paseo-bm agents apart (delta 20260918g):
45
+ * the three main aliases and the fallback aliases `bm-<role>-fallback-<n>`
46
+ * (delta 20260921 §4.4.1). Lookups use the agent's real alias.
47
+ */
48
+
49
+ export { chooseModeId, type ProviderMode } from "./role-mode";
50
+
51
+ /** Separates the role instructions from a system prompt that was already set. */
52
+ export const ROLE_PROMPT_SEPARATOR = "\n\n---\n\n";
53
+
54
+ /** The `agent.create` before-request the daemon hands to the hook. */
55
+ export type AgentCreateRequest = PluginBeforeRequests["agent.create"];
56
+
57
+ /**
58
+ * Returns the request with the role instructions in `config.systemPrompt`, or
59
+ * `undefined` when nothing changes (not a paseo-bm provider, or the prompt
60
+ * already contains the instructions). Never throws.
61
+ *
62
+ * An existing, different system prompt is kept after the instructions,
63
+ * separated by `ROLE_PROMPT_SEPARATOR`.
64
+ */
65
+ export function applyRoleInstructions(
66
+ request: AgentCreateRequest,
67
+ extras: Partial<RoleExtras> = {},
68
+ facts: RuntimeFacts = {},
69
+ ): AgentCreateRequest | undefined {
70
+ try {
71
+ // Typed as required, but never trusted: a malformed request must not throw.
72
+ const config = (request as Partial<AgentCreateRequest> | null | undefined)?.config;
73
+ if (config === null || typeof config !== "object") return undefined;
74
+ const role = roleOfProvider(config.provider);
75
+ if (role === null) return undefined;
76
+ const base = BASE_INSTRUCTIONS[role];
77
+ // The user's additions from the Setup screen come after the base, never instead of it.
78
+ // Runtime facts sit between the two, so manager.ensure and this hook write the same text.
79
+ const instructions = fullInstructions(role, extras[role] ?? "", facts);
80
+
81
+ const existing = typeof config.systemPrompt === "string" ? config.systemPrompt : "";
82
+ if (existing.includes(instructions)) return undefined;
83
+ let systemPrompt: string;
84
+ if (existing.includes(base)) {
85
+ // Set by an older call with the base only: upgrade it in place.
86
+ systemPrompt = existing.replace(base, instructions);
87
+ } else {
88
+ systemPrompt = existing.trim() === "" ? instructions : `${instructions}${ROLE_PROMPT_SEPARATOR}${existing}`;
89
+ }
90
+ return { ...request, config: { ...config, systemPrompt } };
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Returns the request with `config.modeId` set by `chooseModeId`, or
98
+ * `undefined` when nothing changes. `modes` is `null` when the provider's list
99
+ * could not be read. Never throws.
100
+ */
101
+ export function applyRoleMode(
102
+ request: AgentCreateRequest,
103
+ modes: readonly ProviderMode[] | null,
104
+ profileModeId: string | null = null,
105
+ ): AgentCreateRequest | undefined {
106
+ try {
107
+ if (modes === null) return undefined;
108
+ const config = (request as Partial<AgentCreateRequest> | null | undefined)?.config;
109
+ if (config === null || typeof config !== "object") return undefined;
110
+ const id = providerId(config.provider);
111
+ const role = roleOfProvider(id);
112
+ if (id === null || role === null) return undefined;
113
+ const current = typeof config.modeId === "string" && config.modeId.trim() !== "" ? config.modeId : undefined;
114
+ const modeId = chooseModeId(role, modes, current, profileModeId);
115
+ if (modeId === undefined || modeId === current) return undefined;
116
+ if (current !== undefined) {
117
+ console.warn(`[paseo-bm] ${id} was created in mode "${current}", which that role must not use; starting it in "${modeId}" instead.`);
118
+ }
119
+ return { ...request, config: { ...config, modeId } };
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The model a creation request names, or `null`: the daemon's resolved
127
+ * `config.model` when set, else everything after the FIRST `/` of
128
+ * `config.provider` (OpenCode model ids contain `/` themselves, so
129
+ * `bm-worker/anthropic/claude-sonnet-4-6` names `anthropic/claude-sonnet-4-6`).
130
+ */
131
+ function requestModelOf(config: { provider?: unknown; model?: unknown }): string | null {
132
+ if (typeof config.model === "string" && config.model.trim() !== "") return config.model;
133
+ if (typeof config.provider !== "string") return null;
134
+ const slash = config.provider.indexOf("/");
135
+ return slash === -1 || slash === config.provider.length - 1 ? null : config.provider.slice(slash + 1);
136
+ }
137
+
138
+ /**
139
+ * Returns the request with the thinking level and feature values of the
140
+ * Worker's or Reviewer's own profile (delta 20260921 §4.1.1, REQ-062 a), or
141
+ * `undefined` when nothing changes. Never throws.
142
+ *
143
+ * - `thinkingOptionId`: the profile's, only when the creator passed none and
144
+ * the request's model is the profile's model (or names no model): a
145
+ * thinking level belongs to a model and may not exist on another one.
146
+ * - `featureValues`: the profile's, merged under the creator's, whose keys win.
147
+ *
148
+ * The Manager is left alone: `manager.ensure` already passes its profile.
149
+ */
150
+ export function applyRoleProfile(request: AgentCreateRequest, profile: RoleProfile | null): AgentCreateRequest | undefined {
151
+ try {
152
+ if (profile === null) return undefined;
153
+ const config = (request as Partial<AgentCreateRequest> | null | undefined)?.config;
154
+ if (config === null || typeof config !== "object") return undefined;
155
+ const role = roleOfProvider(config.provider);
156
+ if (role === null) return undefined;
157
+ if (role !== "worker" && role !== "reviewer") return undefined;
158
+
159
+ const next: Record<string, unknown> = { ...config };
160
+ let changed = false;
161
+ const current = typeof config.thinkingOptionId === "string" && config.thinkingOptionId.trim() !== "";
162
+ const model = requestModelOf(config);
163
+ if (!current && profile.thinkingOptionId !== null && (model === null || model === profile.model)) {
164
+ next.thinkingOptionId = profile.thinkingOptionId;
165
+ changed = true;
166
+ }
167
+ if (profile.featureValues !== null) {
168
+ const own = config.featureValues;
169
+ const creator = own !== null && typeof own === "object" && !Array.isArray(own) ? own : {};
170
+ const merged = { ...profile.featureValues, ...creator };
171
+ if (JSON.stringify(merged) !== JSON.stringify(own ?? null)) {
172
+ next.featureValues = merged;
173
+ changed = true;
174
+ }
175
+ }
176
+ return changed ? { ...request, config: next as unknown as AgentCreateRequest["config"] } : undefined;
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Returns the request with the start mode and auto-approve of a Worker or
184
+ * Reviewer on an `untiered` or `none` provider (delta 20260921 §4.2.2), or
185
+ * `undefined` when nothing changes. `tiered` and `unknown` providers keep
186
+ * `applyRoleMode`. Never throws.
187
+ */
188
+ export function applyRunPosture(
189
+ request: AgentCreateRequest,
190
+ capability: ProviderCapability,
191
+ modes: readonly ProviderMode[] | null,
192
+ features: readonly ProviderFeature[] | null,
193
+ profileModeId: string | null = null,
194
+ ): AgentCreateRequest | undefined {
195
+ try {
196
+ const config = (request as Partial<AgentCreateRequest> | null | undefined)?.config;
197
+ if (config === null || typeof config !== "object") return undefined;
198
+ const role = roleOfProvider(config.provider);
199
+ if (role === null) return undefined;
200
+ if (!ROLE_GETS_MODE[role]) return undefined;
201
+ const own = config.featureValues;
202
+ const current = {
203
+ ...(typeof config.modeId === "string" && config.modeId.trim() !== "" ? { modeId: config.modeId } : {}),
204
+ ...(own !== null && typeof own === "object" && !Array.isArray(own) ? { featureValues: own } : {}),
205
+ };
206
+ const posture = runPostureOf(role, capability, modes ?? [], features, profileModeId, current);
207
+ if (posture === undefined || (posture.modeId === undefined && posture.featureValues === undefined)) return undefined;
208
+ const next: Record<string, unknown> = { ...config };
209
+ // `null` removes the key: a provider without modes gets neither (review b4).
210
+ if (posture.modeId === null) delete next.modeId;
211
+ else if (posture.modeId !== undefined) next.modeId = posture.modeId;
212
+ if (posture.featureValues === null) delete next.featureValues;
213
+ else if (posture.featureValues !== undefined) next.featureValues = posture.featureValues;
214
+ return { ...request, config: next as unknown as AgentCreateRequest["config"] };
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ /** Instructions, profile settings and start posture together; `undefined` when none changes. */
221
+ export function applyRoleConfig(
222
+ request: AgentCreateRequest,
223
+ extras: Partial<RoleExtras> = {},
224
+ modes: readonly ProviderMode[] | null = null,
225
+ facts: RuntimeFacts = {},
226
+ profileModeId: string | null = null,
227
+ profile: RoleProfile | null = null,
228
+ features: readonly ProviderFeature[] | null = null,
229
+ ): AgentCreateRequest | undefined {
230
+ const withInstructions = applyRoleInstructions(request, extras, facts);
231
+ const withProfile = applyRoleProfile(withInstructions ?? request, profile) ?? withInstructions;
232
+ const capability = capabilityOf(modes);
233
+ const posture =
234
+ capability === "untiered" || capability === "none"
235
+ ? applyRunPosture(withProfile ?? request, capability, modes, features, profileModeId)
236
+ : applyRoleMode(withProfile ?? request, modes, profileModeId);
237
+ return posture ?? withProfile;
238
+ }
239
+
240
+ /**
241
+ * The provider id whose modes are worth a lookup, or `null`. A Reviewer needs
242
+ * the list to recognise a mode it must not run in; a Worker needs it even
243
+ * when its creator already chose a mode, because the capability class decides
244
+ * its auto-approve (delta 20260921 §4.2.1: on OpenCode the Worker's chosen
245
+ * mode is kept but `auto_accept` must still be turned on).
246
+ */
247
+ function modeLookupFor(request: AgentCreateRequest): string | null {
248
+ try {
249
+ const config = (request as Partial<AgentCreateRequest> | null | undefined)?.config;
250
+ const id = providerId(config?.provider);
251
+ const role = roleOfProvider(id);
252
+ if (id === null || role === null) return null;
253
+ return ROLE_GETS_MODE[role] ? id : null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /** Everything the hook needs from the daemon, gathered under one time budget. */
260
+ async function prepare(
261
+ request: AgentCreateRequest,
262
+ paseo: unknown,
263
+ ): Promise<{
264
+ extras: Partial<RoleExtras>;
265
+ modes: ProviderMode[] | null;
266
+ facts: RuntimeFacts;
267
+ profileModeId: string | null;
268
+ profile: RoleProfile | null;
269
+ features: ProviderFeature[] | null;
270
+ }> {
271
+ let extras: Partial<RoleExtras> = {};
272
+ try {
273
+ const home = await installHomeOf(paseo);
274
+ if (home !== null) extras = readRoleExtras(home);
275
+ } catch {
276
+ // Without the additions the agent still gets its base instructions.
277
+ }
278
+ const cwd = typeof request?.config?.cwd === "string" ? request.config.cwd : undefined;
279
+ const id = providerId(request?.config?.provider);
280
+ const role = roleOfProvider(id) ?? undefined;
281
+ // The Worker's or Reviewer's own profile: its mode (bm-msy), and its
282
+ // thinking and features (delta 20260921 §4.1.1). One read, whether or not
283
+ // the mode needs a lookup: a Worker created WITH a mode still gets the
284
+ // thinking set on its profile.
285
+ const profile = role === "worker" || role === "reviewer" ? await profileOf(paseo, id!) : null;
286
+ const lookup = modeLookupFor(request);
287
+ const profileModeId = lookup === null ? null : (profile?.modeId ?? null);
288
+ const modes = lookup === null ? null : await modesFor(paseo, lookup, undefined, cwd);
289
+ // Only an untiered provider (OpenCode) costs the features round trip: its
290
+ // auto-approve toggle is the one feature the posture rule sets (§4.2.2).
291
+ const selection = typeof request?.config?.provider === "string" ? request.config.provider : (id ?? "");
292
+ const features = capabilityOf(modes) === "untiered" ? await featuresFor(paseo, selection, cwd) : null;
293
+ const facts = role === undefined ? {} : await runtimeFactsOf(role, paseo, cwd);
294
+ return { extras, modes, facts, profileModeId, profile, features };
295
+ }
296
+
297
+ function isBmRequest(request: AgentCreateRequest): boolean {
298
+ try {
299
+ return roleOfProvider((request as Partial<AgentCreateRequest> | null | undefined)?.config?.provider) !== null;
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ /** The part of the server context this hook needs; `before` is absent on older hosts. */
306
+ export type RoleHookHost = Partial<Pick<PluginServerContext, "before">>;
307
+
308
+ /**
309
+ * Registers the `before("agent.create")` hook and returns its remover. On a
310
+ * host without `before` it logs one line and returns a no-op.
311
+ */
312
+ export function registerRoleHook(host: RoleHookHost): () => void {
313
+ if (typeof host.before !== "function") {
314
+ console.warn(
315
+ "[paseo-bm] this Paseo host has no before(\"agent.create\") hook; Worker and Reviewer agents will start without role instructions.",
316
+ );
317
+ return () => {};
318
+ }
319
+ const remove = host.before("agent.create", (input, context) => {
320
+ const request = (input as { request?: AgentCreateRequest } | undefined)?.request as AgentCreateRequest;
321
+ // Every agent creation passes here: only paseo-bm's own pay for a lookup.
322
+ if (!isBmRequest(request)) return undefined;
323
+ return (async () => {
324
+ const paseo = (context as { paseo?: unknown } | undefined)?.paseo;
325
+ // The host fails the whole creation if this hook takes longer than 30 s,
326
+ // so everything it looks up is raced as ONE budget: a slow daemon costs
327
+ // the extras, the mode and the facts, never the agent.
328
+ const prepared = await withTimeout(prepare(request, paseo), LOOKUP_TIMEOUT_MS);
329
+ if (prepared === TIMED_OUT) {
330
+ console.warn(
331
+ `[paseo-bm] preparing the role config took longer than ${LOOKUP_TIMEOUT_MS} ms; the agent starts with its role instructions only.`,
332
+ );
333
+ return applyRoleInstructions(request);
334
+ }
335
+ return applyRoleConfig(
336
+ request,
337
+ prepared.extras,
338
+ prepared.modes,
339
+ prepared.facts,
340
+ prepared.profileModeId,
341
+ prepared.profile,
342
+ prepared.features,
343
+ );
344
+ })();
345
+ });
346
+ return typeof remove === "function" ? remove : () => {};
347
+ }