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,552 @@
1
+ /**
2
+ * What the Setup screen says (delta 20260916-setup-screen), and what its
3
+ * Roles & models section shows and saves (delta 20260921 §4.3.1), fallback
4
+ * chains included (§4.4.3), without a renderer.
5
+ *
6
+ * Pure: no React, no React Native, no `server/` import.
7
+ */
8
+ import type {
9
+ BmRole,
10
+ FallbackEntryInput,
11
+ FallbackSettings,
12
+ RoleModelOption,
13
+ RoleSetting,
14
+ RolesOptions,
15
+ RolesSaveFallbackInput,
16
+ RolesSaveSettingsInput,
17
+ RolesSettings,
18
+ SetupStatus,
19
+ } from "../shared/contracts";
20
+ import { MAX_FALLBACK_ENTRIES } from "../shared/fallback";
21
+ import type { Badge, GraphNode } from "./dashboard-model";
22
+ import { errorMessageOf } from "./launch-manager";
23
+
24
+ export type SetupRole = "manager" | "worker" | "reviewer";
25
+ type Tool = SetupStatus["tools"][number];
26
+ type SkillRow = SetupStatus["skills"]["skills"][number];
27
+ type SkillState = SkillRow["claude"];
28
+ export type SkillAgent = "Claude" | "Codex" | "Pi" | "OpenCode";
29
+
30
+ /**
31
+ * The skill columns, in order. Pi and OpenCode (delta 20260921 §4.2.6) are
32
+ * optional in the payload: a column the server did not report is not shown.
33
+ */
34
+ export const SKILL_COLUMNS: ReadonlyArray<{ key: "claude" | "codex" | "pi" | "opencode"; agent: SkillAgent }> = [
35
+ { key: "claude", agent: "Claude" },
36
+ { key: "codex", agent: "Codex" },
37
+ { key: "pi", agent: "Pi" },
38
+ { key: "opencode", agent: "OpenCode" },
39
+ ];
40
+
41
+ export const SETUP_ROLES: ReadonlyArray<{ role: SetupRole; label: string; mark: GraphNode["kind"] }> = [
42
+ { role: "manager", label: "Manager", mark: "request" },
43
+ { role: "worker", label: "Worker", mark: "worker" },
44
+ { role: "reviewer", label: "Reviewer", mark: "reviewer" },
45
+ ];
46
+
47
+ /** Numeric comparison of `0.2.10` / `v0.25.0`; null when either is not a version. */
48
+ export function compareVersions(a: string, b: string): number | null {
49
+ const parse = (value: string) => /(\d+)\.(\d+)(?:\.(\d+))?/.exec(value)?.slice(1).map((part) => Number(part ?? 0));
50
+ const left = parse(a);
51
+ const right = parse(b);
52
+ if (left === undefined || right === undefined) return null;
53
+ for (let index = 0; index < 3; index += 1) {
54
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
55
+ if (diff !== 0) return diff;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ export function toolBadge(tool: Tool): Badge {
61
+ if (tool.path === null) return tool.required ? { text: "Missing", tone: "danger" } : { text: "Not installed", tone: "muted" };
62
+ if (tool.version !== null && tool.latestKnown !== null && (compareVersions(tool.version, tool.latestKnown) ?? 0) < 0) {
63
+ return { text: `${tool.version} · ${tool.latestKnown} available`, tone: "warning" };
64
+ }
65
+ return { text: tool.version ?? "Installed", tone: "success" };
66
+ }
67
+
68
+ export function skillBadge(agent: SkillAgent, state: SkillState): Badge {
69
+ switch (state) {
70
+ case "ok":
71
+ return { text: `${agent} ✓`, tone: "success" };
72
+ case "broken":
73
+ return { text: `${agent} broken`, tone: "danger" };
74
+ default:
75
+ return { text: `${agent} missing`, tone: "warning" };
76
+ }
77
+ }
78
+
79
+ /** One chip per skill column the row has, in column order. */
80
+ export function skillChips(skill: SkillRow): Badge[] {
81
+ return SKILL_COLUMNS.flatMap(({ key, agent }) => {
82
+ const state = skill[key];
83
+ return state === undefined ? [] : [skillBadge(agent, state)];
84
+ });
85
+ }
86
+
87
+ /** Where each agent's skills were looked for, one entry per reported column. */
88
+ export function skillDirsText(dirs: SetupStatus["skills"]["dirs"]): string {
89
+ return [
90
+ `Claude: ${dirs.claude}`,
91
+ `Codex: ${dirs.shared} or ${dirs.codex}`,
92
+ ...(dirs.pi === undefined ? [] : [`Pi: ${dirs.pi}`]),
93
+ ...(dirs.opencode === undefined ? [] : [`OpenCode: ${dirs.opencode}`]),
94
+ ].join(" · ");
95
+ }
96
+
97
+ /** One line at the top: is this machine ready for the Beads agents? */
98
+ export function setupHeadline(status: SetupStatus): Badge {
99
+ const missingTools = status.tools.filter((tool) => tool.required && tool.path === null).map((tool) => tool.id);
100
+ const required = status.skills.skills.filter((skill) => skill.required).length;
101
+ const counts = SKILL_COLUMNS.flatMap(({ key, agent }) => {
102
+ const missing = status.skills.missingRequired[key];
103
+ return missing === undefined ? [] : [{ agent, missing }];
104
+ });
105
+ const skills = `skills: ${counts.map((count) => `${count.agent} ${required - count.missing}/${required}`).join(", ")}`;
106
+ if (missingTools.length > 0) {
107
+ return { text: `Missing ${missingTools.join(" and ")} — the Worker cannot manage beads without it · ${skills}`, tone: "danger" };
108
+ }
109
+ const readyForOne = counts.some((count) => count.missing === 0);
110
+ return { text: `br and bv ready · ${skills}`, tone: readyForOne ? "success" : "warning" };
111
+ }
112
+
113
+ /**
114
+ * One warning per role whose last new agent had no Paseo tools (delta 20260921
115
+ * §4.2.4, REQ-063 d); nothing for `ok`, `unknown` or an older server.
116
+ */
117
+ export function paseoToolsWarnings(status: SetupStatus): string[] {
118
+ const seen = status.paseoTools;
119
+ if (seen === undefined) return [];
120
+ return (["manager", "worker"] as const).flatMap((role) => {
121
+ const entry = seen[role];
122
+ if (entry === null || entry.state !== "missing") return [];
123
+ const name = role === "manager" ? "Manager" : "Worker";
124
+ return [
125
+ `The last ${name} (${entry.agentId}) runs on ${entry.provider} without Paseo tools, so it cannot create or message other agents. On Pi, install the pi-mcp-adapter extension.`,
126
+ ];
127
+ });
128
+ }
129
+
130
+ export function extraCounter(length: number, max: number): string {
131
+ return `${length.toLocaleString("en-US")} / ${max.toLocaleString("en-US")} characters`;
132
+ }
133
+
134
+ export function installWarning(tool: Tool): string {
135
+ return tool.installCommand?.startsWith("brew ")
136
+ ? `This runs Homebrew on this machine: ${tool.installCommand}`
137
+ : `This downloads and runs the project's install script from GitHub on this machine: ${tool.installCommand ?? ""}`;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Roles & models (delta 20260921 §4.3.1, REQ-064 a/d, REQ-063 h).
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** Shown under the Roles & models rows at all times. */
145
+ export const ROLES_APPLY_NOTICE = "Changes apply to agents created after you save. Running agents keep their model and thinking.";
146
+
147
+ /** What a save refused with `E_ROLE_SETTINGS_CONFLICT` says, word for word. */
148
+ export const ROLES_CONFLICT_MESSAGE = "The configuration changed elsewhere; reopen Roles & models.";
149
+
150
+ /** Display names of the base providers paseo-bm documents; any other id is shown as it is. */
151
+ const PROVIDER_LABELS: ReadonlyMap<string, string> = new Map([
152
+ ["claude", "Claude"],
153
+ ["codex", "Codex"],
154
+ ["opencode", "OpenCode"],
155
+ ["pi", "Pi"],
156
+ ]);
157
+
158
+ export function providerLabel(provider: string): string {
159
+ return PROVIDER_LABELS.get(provider) ?? provider;
160
+ }
161
+
162
+ const isRoleAlias = (provider: string): boolean => /^bm-/i.test(provider.trim());
163
+
164
+ /** The options of a role's base provider, or `undefined` when they are for another provider (or not loaded). */
165
+ function optionsFor(provider: string | null, options: RolesOptions | undefined): RolesOptions | undefined {
166
+ return provider !== null && options?.provider === provider ? options : undefined;
167
+ }
168
+
169
+ /**
170
+ * One role row: `<provider> · <model> · thinking <id | provider default> · mode <label>`.
171
+ * Labels come from `roles.options` of the role's base provider when it is
172
+ * loaded, ids otherwise; the mode part is left out when no mode is set.
173
+ */
174
+ export function roleSettingText(setting: RoleSetting, options?: RolesOptions): string {
175
+ const listed = optionsFor(setting.baseProvider, options);
176
+ const model = setting.model === null ? "model not set" : (listed?.models.find((entry) => entry.id === setting.model)?.label ?? setting.model);
177
+ const parts = [
178
+ setting.baseProvider === null ? "provider not set" : providerLabel(setting.baseProvider),
179
+ model,
180
+ `thinking ${setting.thinkingOptionId ?? "provider default"}`,
181
+ ];
182
+ if (setting.modeId !== null) {
183
+ parts.push(`mode ${listed?.modes.find((entry) => entry.id === setting.modeId)?.label ?? setting.modeId}`);
184
+ }
185
+ return parts.join(" · ");
186
+ }
187
+
188
+ /**
189
+ * The distinct base providers of the roles, then of their fallback entries,
190
+ * whose `roles.options` label the rows; never a `bm-*` alias.
191
+ */
192
+ export function rowOptionProviders(settings: RolesSettings): string[] {
193
+ const out: string[] = [];
194
+ const add = (provider: string | null) => {
195
+ if (provider !== null && !isRoleAlias(provider) && !out.includes(provider)) out.push(provider);
196
+ };
197
+ for (const setting of settings.roles) add(setting.baseProvider);
198
+ for (const chain of fallbackBlocks(settings)) for (const entry of chain.entries) add(entry.baseProvider);
199
+ return out;
200
+ }
201
+
202
+ export interface RoleRow {
203
+ role: BmRole;
204
+ label: string;
205
+ mark: GraphNode["kind"];
206
+ setting: RoleSetting;
207
+ text: string;
208
+ }
209
+
210
+ /** The rows of the section, in the order `roles.settings` returned them. */
211
+ export function roleRows(settings: RolesSettings, optionsOf: (provider: string) => RolesOptions | undefined): RoleRow[] {
212
+ return settings.roles.map((setting) => {
213
+ const known = SETUP_ROLES.find((entry) => entry.role === setting.role);
214
+ const options = setting.baseProvider === null ? undefined : optionsOf(setting.baseProvider);
215
+ return {
216
+ role: setting.role,
217
+ label: known?.label ?? setting.role,
218
+ mark: known?.mark ?? "worker",
219
+ setting,
220
+ text: roleSettingText(setting, options),
221
+ };
222
+ });
223
+ }
224
+
225
+ /** What the Edit form holds. `null` thinking / mode means "not set". */
226
+ export interface RoleDraft {
227
+ baseProvider: string;
228
+ model: string | null;
229
+ thinkingOptionId: string | null;
230
+ modeId: string | null;
231
+ }
232
+
233
+ /** A choice of the form; `id: null` is the "not set" choice. */
234
+ export interface RoleChoice {
235
+ id: string | null;
236
+ label: string;
237
+ }
238
+
239
+ /** The draft an Edit form opens with: the role as it is saved now. */
240
+ export function roleDraftOf(setting: RoleSetting): RoleDraft {
241
+ return {
242
+ baseProvider: setting.baseProvider ?? "",
243
+ model: setting.model,
244
+ thinkingOptionId: setting.thinkingOptionId,
245
+ modeId: setting.modeId,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The base providers the form offers: those `roles.settings` reports as
251
+ * available, plus the role's current one when it is missing from that list
252
+ * (so the form always shows what is selected). Never a `bm-*` alias.
253
+ */
254
+ export function providerChoices(available: readonly string[], current: string | null): string[] {
255
+ const out = available.filter((provider) => provider.trim() !== "" && !isRoleAlias(provider));
256
+ if (current !== null && current.trim() !== "" && !isRoleAlias(current) && !out.includes(current)) out.unshift(current);
257
+ return out;
258
+ }
259
+
260
+ /** Thinking choices of a model: "Provider default" first; none (the field is hidden) when the model has no levels. */
261
+ export function thinkingChoices(model: RoleModelOption | null | undefined): RoleChoice[] {
262
+ if (model === null || model === undefined || model.thinkingOptions.length === 0) return [];
263
+ const fallback = model.defaultThinkingOptionId;
264
+ const fallbackLabel = fallback === null ? null : (model.thinkingOptions.find((option) => option.id === fallback)?.label ?? fallback);
265
+ return [
266
+ { id: null, label: fallbackLabel === null ? "Provider default" : `Provider default (${fallbackLabel})` },
267
+ ...model.thinkingOptions.map((option) => ({ id: option.id, label: option.label })),
268
+ ];
269
+ }
270
+
271
+ /**
272
+ * Mode choices of a provider for a role: "Not set" first. None (the field is
273
+ * hidden) for capability `none` or when the provider lists no mode. The
274
+ * Reviewer on a `tiered` provider is never offered a `dangerous` or
275
+ * `planning` mode (§4.3.3).
276
+ */
277
+ export function modeChoices(role: BmRole, options: RolesOptions | undefined): RoleChoice[] {
278
+ if (options === undefined || options.capability === "none") return [];
279
+ const modes = options.modes.filter((mode) => {
280
+ if (role !== "reviewer" || options.capability !== "tiered") return true;
281
+ const tier = (mode.colorTier ?? "").toLowerCase();
282
+ return tier !== "dangerous" && tier !== "planning";
283
+ });
284
+ if (modes.length === 0) return [];
285
+ return [{ id: null, label: "Not set" }, ...modes.map((mode) => ({ id: mode.id, label: mode.label }))];
286
+ }
287
+
288
+ /** `~$<in> / $<out> per 1M tokens`, or `null` when the model has no listed cost. */
289
+ export function modelPriceText(model: RoleModelOption | null | undefined): string | null {
290
+ const cost = model?.cost ?? null;
291
+ return cost === null ? null : `~$${cost.inputUsdPerMTok} / $${cost.outputUsdPerMTok} per 1M tokens`;
292
+ }
293
+
294
+ export interface RoleFormView {
295
+ /** The draft with every value the chosen provider and model do not offer dropped. */
296
+ draft: RoleDraft;
297
+ providers: string[];
298
+ models: RoleModelOption[];
299
+ model: RoleModelOption | null;
300
+ /** Empty: the Thinking field is hidden. */
301
+ thinking: RoleChoice[];
302
+ /** Empty: the Mode field is hidden. */
303
+ modes: RoleChoice[];
304
+ price: string | null;
305
+ /** A line under the Mode field, e.g. when Paseo could not list the modes and saving clears the one set. */
306
+ modeNote: string | null;
307
+ /** Why Save cannot run yet; `null` when the draft is complete. */
308
+ blocker: string | null;
309
+ /** The draft differs from what is saved. */
310
+ changed: boolean;
311
+ }
312
+
313
+ /**
314
+ * Everything the Edit form shows, from the saved role, the available
315
+ * providers, the user's draft and `roles.options` of the drafted provider
316
+ * (`undefined` while it loads or when it failed).
317
+ */
318
+ export function roleFormView(input: {
319
+ role: BmRole;
320
+ setting: RoleSetting;
321
+ available: readonly string[];
322
+ draft: RoleDraft;
323
+ options: RolesOptions | undefined;
324
+ }): RoleFormView {
325
+ const { role, setting, draft } = input;
326
+ const providers = providerChoices(input.available, setting.baseProvider);
327
+ const options = draft.baseProvider === "" ? undefined : optionsFor(draft.baseProvider, input.options);
328
+ if (options === undefined) {
329
+ return {
330
+ draft,
331
+ providers,
332
+ models: [],
333
+ model: null,
334
+ thinking: [],
335
+ modes: [],
336
+ price: null,
337
+ modeNote: null,
338
+ blocker: draft.baseProvider === "" ? "Pick a provider." : `Loading the models of ${providerLabel(draft.baseProvider)}…`,
339
+ changed: draftChanged(setting, draft),
340
+ };
341
+ }
342
+ const model = options.models.find((entry) => entry.id === draft.model) ?? null;
343
+ const thinking = thinkingChoices(model);
344
+ const modes = modeChoices(role, options);
345
+ const normalized: RoleDraft = {
346
+ baseProvider: draft.baseProvider,
347
+ model: model?.id ?? null,
348
+ thinkingOptionId: thinking.some((choice) => choice.id !== null && choice.id === draft.thinkingOptionId) ? draft.thinkingOptionId : null,
349
+ modeId: modes.some((choice) => choice.id !== null && choice.id === draft.modeId) ? draft.modeId : null,
350
+ };
351
+ const label = providerLabel(draft.baseProvider);
352
+ const blocker =
353
+ normalized.model !== null ? null : options.models.length === 0 ? `Paseo lists no model for ${label}; pick another provider.` : "Pick a model.";
354
+ const modeNote =
355
+ options.capability === "unknown" && draft.modeId !== null
356
+ ? `Paseo could not list the modes of ${label}; saving clears the mode \`${draft.modeId}\`.`
357
+ : null;
358
+ return {
359
+ draft: normalized,
360
+ providers,
361
+ models: options.models,
362
+ model,
363
+ thinking,
364
+ modes,
365
+ price: modelPriceText(model),
366
+ modeNote,
367
+ blocker,
368
+ changed: draftChanged(setting, normalized),
369
+ };
370
+ }
371
+
372
+ /** True when the draft would change the saved role. */
373
+ export function draftChanged(setting: RoleSetting, draft: RoleDraft): boolean {
374
+ return (
375
+ (setting.baseProvider ?? "") !== draft.baseProvider ||
376
+ setting.model !== draft.model ||
377
+ setting.thinkingOptionId !== draft.thinkingOptionId ||
378
+ setting.modeId !== draft.modeId
379
+ );
380
+ }
381
+
382
+ /** The `roles.save-settings` input, or `null` while the draft has no provider or model. */
383
+ export function saveSettingsInput(revision: string, role: BmRole, draft: RoleDraft): RolesSaveSettingsInput | null {
384
+ if (draft.baseProvider === "" || draft.model === null) return null;
385
+ return {
386
+ revision,
387
+ role,
388
+ baseProvider: draft.baseProvider,
389
+ model: draft.model,
390
+ thinkingOptionId: draft.thinkingOptionId,
391
+ modeId: draft.modeId,
392
+ };
393
+ }
394
+
395
+ /** What a successful save shows under its row: "Saved." then each returned warning. */
396
+ export function savedNotes(result: { warnings: readonly string[] }): Badge[] {
397
+ return [{ text: "Saved.", tone: "success" }, ...result.warnings.map((warning) => ({ text: warning, tone: "warning" as const }))];
398
+ }
399
+
400
+ export function isSettingsConflict(error: unknown): boolean {
401
+ return /\bE_ROLE_SETTINGS_CONFLICT\b/.test(errorMessageOf(error));
402
+ }
403
+
404
+ /** What a failed save shows: the fixed conflict sentence, otherwise the server's message. */
405
+ export function saveErrorText(error: unknown): string {
406
+ return isSettingsConflict(error) ? ROLES_CONFLICT_MESSAGE : errorMessageOf(error);
407
+ }
408
+
409
+ /** `roles.settings` after a save, before the refetch lands: the new revision and the saved role in place. */
410
+ export function applySavedRole(settings: RolesSettings, result: { revision: string; role: RoleSetting }): RolesSettings {
411
+ return {
412
+ ...settings,
413
+ revision: result.revision,
414
+ roles: settings.roles.map((setting) => (setting.role === result.role.role ? result.role : setting)),
415
+ };
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Fallback chains (delta 20260921 §4.3.1, §4.4.3, REQ-065 f).
420
+ // ---------------------------------------------------------------------------
421
+
422
+ /**
423
+ * The policy row under a role: `On a usage limit: (•) Ask me ( ) Auto switch ( ) Off`.
424
+ * "Ask me" stays the default; "Auto switch" (phase 2a-18, §4.6) is chosen per role.
425
+ */
426
+ export const FALLBACK_POLICY_CHOICES: ReadonlyArray<{ id: FallbackDraft["policy"]; label: string }> = [
427
+ { id: "ask", label: "Ask me" },
428
+ { id: "auto", label: "Auto switch" },
429
+ { id: "off", label: "Off" },
430
+ ];
431
+
432
+ /**
433
+ * The warning shown under a role's policy while "Auto switch" is chosen (§4.6,
434
+ * REQ-067 a): the plugin replaces the stopped agent without asking, which can
435
+ * cost money; for the Manager, the chat being used can be replaced. `null` for
436
+ * the other policies.
437
+ */
438
+ export function fallbackPolicyWarning(role: BmRole, policy: FallbackDraft["policy"]): string | null {
439
+ if (policy !== "auto") return null;
440
+ const cost = `Auto switch replaces a stopped ${ROLE_NAMES[role]} without asking you; a fallback on a provider that bills by the token can cost money.`;
441
+ return role === "manager" ? `${cost} The chat you use may be replaced.` : cost;
442
+ }
443
+
444
+ const ROLE_NAMES: Readonly<Record<BmRole, string>> = { manager: "Manager", worker: "Worker", reviewer: "Reviewer" };
445
+
446
+ /**
447
+ * The chains to show, in role order: one block per role present in
448
+ * `roles.settings.fallback`. The server decides which roles are offered
449
+ * (`FALLBACK_ROLES`), so a later phase enables more blocks without a client change.
450
+ */
451
+ export function fallbackBlocks(settings: RolesSettings): FallbackSettings[] {
452
+ const chains = settings.fallback;
453
+ if (chains === null || chains === undefined) return [];
454
+ return SETUP_ROLES.map((entry) => chains[entry.role]).filter((chain): chain is FallbackSettings => chain !== undefined);
455
+ }
456
+
457
+ /** What a chain's block holds while the user edits it. */
458
+ export interface FallbackDraft {
459
+ policy: "ask" | "off" | "auto";
460
+ entries: FallbackEntryInput[];
461
+ }
462
+
463
+ /** The draft a block starts from: the chain as saved. */
464
+ export function fallbackDraftOf(chain: FallbackSettings): FallbackDraft {
465
+ return {
466
+ policy: chain.policy,
467
+ entries: chain.entries.map(({ baseProvider, model, thinkingOptionId, modeId }) => ({ baseProvider, model, thinkingOptionId, modeId })),
468
+ };
469
+ }
470
+
471
+ /** One entry row: `<Provider> · <model> · thinking <id | provider default>`, plus the mode when one is set. */
472
+ export function fallbackEntryText(entry: FallbackEntryInput, options?: RolesOptions): string {
473
+ const listed = optionsFor(entry.baseProvider, options);
474
+ const parts = [
475
+ providerLabel(entry.baseProvider),
476
+ listed?.models.find((model) => model.id === entry.model)?.label ?? entry.model,
477
+ `thinking ${entry.thinkingOptionId ?? "provider default"}`,
478
+ ];
479
+ if (entry.modeId !== null) parts.push(`mode ${listed?.modes.find((mode) => mode.id === entry.modeId)?.label ?? entry.modeId}`);
480
+ return parts.join(" · ");
481
+ }
482
+
483
+ /** The listed price of a saved entry, as the Edit form shows it; `null` when unknown. */
484
+ export function fallbackPriceText(entry: FallbackSettings["entries"][number]): string | null {
485
+ return entry.cost === null ? null : `~$${entry.cost.inputUsdPerMTok} / $${entry.cost.outputUsdPerMTok} per 1M tokens`;
486
+ }
487
+
488
+ /** True while another entry fits (at most three per role). */
489
+ export function canAddFallback(draft: FallbackDraft): boolean {
490
+ return draft.entries.length < MAX_FALLBACK_ENTRIES;
491
+ }
492
+
493
+ /** The draft with `entry` appended; unchanged when the chain is full. */
494
+ export function addFallback(draft: FallbackDraft, entry: FallbackEntryInput): FallbackDraft {
495
+ return canAddFallback(draft) ? { ...draft, entries: [...draft.entries, { ...entry }] } : draft;
496
+ }
497
+
498
+ /** The draft with entry `index` replaced (an entry edited in place). */
499
+ export function replaceFallback(draft: FallbackDraft, index: number, entry: FallbackEntryInput): FallbackDraft {
500
+ if (index < 0 || index >= draft.entries.length) return draft;
501
+ return { ...draft, entries: draft.entries.map((current, at) => (at === index ? { ...entry } : current)) };
502
+ }
503
+
504
+ /** The draft without entry `index`; the entries after it move up, so positions stay 1…n. */
505
+ export function removeFallback(draft: FallbackDraft, index: number): FallbackDraft {
506
+ if (index < 0 || index >= draft.entries.length) return draft;
507
+ return { ...draft, entries: draft.entries.filter((_entry, at) => at !== index) };
508
+ }
509
+
510
+ /** The draft with entry `index` moved one place up (`-1`) or down (`1`); unchanged at either end. */
511
+ export function moveFallback(draft: FallbackDraft, index: number, delta: -1 | 1): FallbackDraft {
512
+ const target = index + delta;
513
+ if (index < 0 || index >= draft.entries.length || target < 0 || target >= draft.entries.length) return draft;
514
+ const entries = [...draft.entries];
515
+ [entries[index], entries[target]] = [entries[target]!, entries[index]!];
516
+ return { ...draft, entries };
517
+ }
518
+
519
+ /** True when the draft would change the saved chain. */
520
+ export function fallbackDraftChanged(chain: FallbackSettings, draft: FallbackDraft): boolean {
521
+ const saved = fallbackDraftOf(chain);
522
+ return saved.policy !== draft.policy || JSON.stringify(saved.entries) !== JSON.stringify(draft.entries);
523
+ }
524
+
525
+ /** The `roles.save-fallback` input; `revision` is the one the block had when the user started editing it. */
526
+ export function saveFallbackInput(revision: string, role: BmRole, draft: FallbackDraft): RolesSaveFallbackInput {
527
+ return { revision, role, policy: draft.policy, entries: draft.entries.map((entry) => ({ ...entry })) };
528
+ }
529
+
530
+ /**
531
+ * An entry as the Edit form's `setting`, so the same form (`roleFormView`)
532
+ * edits a fallback entry: `null` opens an empty form for "Add fallback".
533
+ */
534
+ export function entryAsSetting(role: BmRole, entry: FallbackEntryInput | null): RoleSetting {
535
+ return {
536
+ role,
537
+ providerId: role === "manager" ? "bm-manager" : role === "worker" ? "bm-worker" : "bm-reviewer",
538
+ baseProvider: entry?.baseProvider ?? null,
539
+ label: null,
540
+ model: entry?.model ?? null,
541
+ thinkingOptionId: entry?.thinkingOptionId ?? null,
542
+ modeId: entry?.modeId ?? null,
543
+ featureValues: {},
544
+ capability: "unknown",
545
+ };
546
+ }
547
+
548
+ /** The entry a completed Edit form describes, or `null` while it has no provider or model. */
549
+ export function entryOfDraft(draft: RoleDraft): FallbackEntryInput | null {
550
+ if (draft.baseProvider === "" || draft.model === null) return null;
551
+ return { baseProvider: draft.baseProvider, model: draft.model, thinkingOptionId: draft.thinkingOptionId, modeId: draft.modeId };
552
+ }