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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- 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
|
+
}
|