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,306 @@
1
+ /**
2
+ * The fallback card's server side (delta 20260921 §4.4.6, §4.4.10, REQ-065 c).
3
+ *
4
+ * - `BM-FALLBACK`: a new `pending` incident with a `managerId` is told to that
5
+ * Manager's chat through the notice queue (the Manager may be running, F13),
6
+ * and so is every later decision, with its new `status` and `replacement`.
7
+ * The chat renders the block as a card; the card's state always comes from
8
+ * `fallback.incidents`, never from the text.
9
+ * - `fallback.incidents`: the recorded incidents, filtered by workspace or ids.
10
+ * - `fallback.act`: `dismiss` ("I'll handle it") marks the incident
11
+ * `dismissed` and touches no agent. `switch` (§4.4.7) and `wait` (§4.4.9)
12
+ * are handed in by the modules that implement them.
13
+ *
14
+ * Handlers throw only coded `DashboardError`s; the notice never throws.
15
+ */
16
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
17
+ import { onFallbackIncident, readIncidents, updateIncidents } from "./fallback-state";
18
+ import { FALLBACK_NOTICE_MARKER } from "./notices";
19
+ import { enqueue as defaultEnqueue, type NoticeOutcome, type NoticePaseo } from "./notice-queue";
20
+ import { providerId } from "./provider-id";
21
+ import { asRecord, nonEmpty, reasonOf } from "./role-choices";
22
+ import { installHomeOf } from "./role-extras";
23
+ import { TIMED_OUT, withTimeout } from "./role-mode";
24
+ import { FALLBACK_CLASS_LABELS } from "../shared/bm-fallback";
25
+ import { DashboardError, fallbackActRpc, fallbackIncidentsRpc, type FallbackActInput, type FallbackIncident } from "../shared/contracts";
26
+
27
+ /** Longest message line of the notice. */
28
+ export const NOTICE_MESSAGE_CHARS = 300;
29
+
30
+ const defaultLog = (message: string): void => console.warn(message);
31
+
32
+ /**
33
+ * The `BM-FALLBACK` block of an incident, word for word (agent-facing, so
34
+ * English). `baseOf` gives the base provider an alias extends (`null` when
35
+ * unknown, written `unknown`). `closing` replaces the Manager's closing line —
36
+ * the Worker of a switched Reviewer gets its instructions there (§4.5.1).
37
+ */
38
+ export function fallbackNotice(incident: FallbackIncident, baseOf: (alias: string) => string | null, closing?: string): string {
39
+ const alias = providerId(incident.agentProvider) ?? incident.agentProvider;
40
+ const message = incident.message.replace(/\s+/g, " ").trim().slice(0, NOTICE_MESSAGE_CHARS);
41
+ const candidate =
42
+ incident.candidate === null
43
+ ? "none"
44
+ : `${incident.candidate.alias} · ${incident.candidate.baseProvider} · ${incident.candidate.model} · thinking ${incident.candidate.thinkingOptionId ?? "provider default"}`;
45
+ return [
46
+ FALLBACK_NOTICE_MARKER,
47
+ `incident: ${incident.id}`,
48
+ `role: ${incident.role}`,
49
+ `agent: ${incident.agentId}`,
50
+ `requestId: ${incident.requestId ?? "none"}`,
51
+ `status: ${incident.status}`,
52
+ `class: ${FALLBACK_CLASS_LABELS[incident.class]}`,
53
+ `provider: ${alias} (${baseOf(alias) ?? "unknown"}) · ${incident.agentModel ?? "unknown"}`,
54
+ `message: ${message === "" ? "none" : message}`,
55
+ `resetsAt: ${incident.resetsAt ?? "unknown"}`,
56
+ `candidate: ${candidate}`,
57
+ `replacement: ${incident.replacementId ?? "none"}`,
58
+ "",
59
+ closing ??
60
+ `The ${incident.role} stopped because of its provider plan. The user decides on the card in this chat. Tell the user in one line; do not create an agent yourself.`,
61
+ ].join("\n");
62
+ }
63
+
64
+ /** The `extends` of every alias, from one `config.get()` under the lookup budget; `{}` when unreadable. Never throws. */
65
+ export async function aliasBases(paseo: unknown): Promise<Record<string, string>> {
66
+ const config = (paseo as { config?: { get?: unknown } } | null | undefined)?.config;
67
+ if (typeof config?.get !== "function") return {};
68
+ try {
69
+ const result = await withTimeout(config.get.call(config) as Promise<{ config?: unknown } | null | undefined>);
70
+ if (result === TIMED_OUT) return {};
71
+ const out: Record<string, string> = {};
72
+ for (const [id, entry] of Object.entries(asRecord(asRecord(result?.config)?.["providers"]) ?? {})) {
73
+ const base = nonEmpty(asRecord(entry)?.["extends"]);
74
+ if (base !== null) out[id] = base;
75
+ }
76
+ return out;
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ export interface FallbackNoticeDeps {
83
+ enqueue?: (targetId: string, kind: string, text: string, paseo?: NoticePaseo) => Promise<NoticeOutcome>;
84
+ log?: (message: string) => void;
85
+ }
86
+
87
+ /**
88
+ * Tells the incident's Manager chat (kind `BM-FALLBACK`, through the notice
89
+ * queue). No `managerId` → nothing (only the pill shows it). Never throws.
90
+ */
91
+ export async function notifyFallback(incident: FallbackIncident, paseo: unknown, deps: FallbackNoticeDeps = {}): Promise<NoticeOutcome | null> {
92
+ if (incident.managerId === null) return null;
93
+ const log = deps.log ?? defaultLog;
94
+ try {
95
+ const bases = await aliasBases(paseo);
96
+ const text = fallbackNotice(incident, (alias) => bases[alias] ?? null);
97
+ return await (deps.enqueue ?? defaultEnqueue)(incident.managerId, FALLBACK_NOTICE_MARKER, text, paseo as NoticePaseo);
98
+ } catch (error) {
99
+ log(`[paseo-bm] telling ${incident.managerId} about fallback incident ${incident.id} failed: ${reasonOf(error)}`);
100
+ return null;
101
+ }
102
+ }
103
+
104
+ export interface FallbackRpcDeps extends FallbackNoticeDeps {
105
+ /** The install home; the plugin looks it up, tests pass one. */
106
+ home?: string | null;
107
+ now?: () => Date;
108
+ }
109
+
110
+ /** The install home, raced against the lookup budget. */
111
+ async function homeOf(paseo: unknown, deps: FallbackRpcDeps): Promise<string | null> {
112
+ if (deps.home !== undefined) return deps.home;
113
+ const found = await withTimeout(installHomeOf(paseo));
114
+ return found === TIMED_OUT ? null : found;
115
+ }
116
+
117
+ /** Handler body of `fallback.incidents`: oldest first; an unreadable file or unknown home reads as none. */
118
+ export async function handleFallbackIncidents(
119
+ input: { workspaceId?: string; ids?: string[] },
120
+ paseo: unknown,
121
+ deps: FallbackRpcDeps = {},
122
+ ): Promise<{ incidents: FallbackIncident[] }> {
123
+ const home = await homeOf(paseo, deps);
124
+ if (home === null) return { incidents: [] };
125
+ const ids = input?.ids === undefined ? null : new Set(input.ids);
126
+ const incidents = readIncidents(home, deps.log ?? defaultLog).incidents.filter(
127
+ (incident) => (input?.workspaceId === undefined || incident.workspaceId === input.workspaceId) && (ids === null || ids.has(incident.id)),
128
+ );
129
+ return { incidents };
130
+ }
131
+
132
+ /** One of the card's actions on a `pending` incident; returns the incident as written. */
133
+ export type FallbackAction = (incident: FallbackIncident, paseo: unknown, deps: FallbackRpcDeps) => Promise<FallbackIncident>;
134
+
135
+ /**
136
+ * Moves one incident from `pending` under the incidents mutex: `change` gets
137
+ * it and returns its new state. Throws `E_FALLBACK_NOT_FOUND` (unknown id, or
138
+ * no usable incidents file) and `E_FALLBACK_NOT_PENDING`.
139
+ */
140
+ export async function decidePending(
141
+ home: string,
142
+ incidentId: string,
143
+ change: (incident: FallbackIncident) => FallbackIncident,
144
+ log: (message: string) => void = defaultLog,
145
+ ): Promise<FallbackIncident> {
146
+ let decided: FallbackIncident | null = null;
147
+ const written = await updateIncidents(
148
+ home,
149
+ (incidents) => {
150
+ const index = incidents.findIndex((incident) => incident.id === incidentId);
151
+ if (index === -1) throw new DashboardError("E_FALLBACK_NOT_FOUND", `no fallback incident ${incidentId}`);
152
+ const current = incidents[index]!;
153
+ if (current.status !== "pending") {
154
+ throw new DashboardError("E_FALLBACK_NOT_PENDING", `incident ${incidentId} is ${current.status}, not pending`);
155
+ }
156
+ decided = change(current);
157
+ return incidents.map((incident, at) => (at === index ? decided! : incident));
158
+ },
159
+ log,
160
+ );
161
+ if (written === null || decided === null) throw new DashboardError("E_FALLBACK_NOT_FOUND", "the fallback incidents file cannot be read");
162
+ return decided;
163
+ }
164
+
165
+ /** `dismiss` ("I'll handle it", §4.4.10): `dismissed`, no agent touched. */
166
+ export const dismissIncident: FallbackAction = async (incident, _paseo, deps) => {
167
+ const home = await homeOf(_paseo, deps);
168
+ if (home === null) throw new DashboardError("E_FALLBACK_NOT_FOUND", "paseo-bm cannot find its install home");
169
+ const now = (deps.now ?? (() => new Date()))().toISOString();
170
+ return decidePending(home, incident.id, (current) => ({ ...current, status: "dismissed", decidedAt: now }), deps.log ?? defaultLog);
171
+ };
172
+
173
+ export interface FallbackActions {
174
+ switch?: FallbackAction;
175
+ wait?: FallbackAction;
176
+ /** Sends a switched Reviewer's instructions to its Worker again (§4.5.1, §7). */
177
+ resend?: FallbackAction;
178
+ }
179
+
180
+ let actionQueue: Promise<unknown> = Promise.resolve();
181
+
182
+ /** Runs `work` after every card action queued before it; a failure does not block the next one. */
183
+ function oneActionAtATime<T>(work: () => Promise<T>): Promise<T> {
184
+ const run = actionQueue.then(work, work);
185
+ actionQueue = run.catch(() => undefined);
186
+ return run;
187
+ }
188
+
189
+ /**
190
+ * Handler body of `fallback.act`. Card actions run ONE AT A TIME (review b6):
191
+ * the incident is read and checked `pending` inside that lock, and the action
192
+ * runs to its end before the next one reads it. So a Switch can never create a
193
+ * Worker for an incident a concurrent Wait or Dismiss already decided, and a
194
+ * second action on the same incident always finds it decided. Then the Manager
195
+ * chat is told the new status. An action this build does not have yet fails
196
+ * with its coded error.
197
+ */
198
+ export function handleFallbackAct(
199
+ input: FallbackActInput,
200
+ paseo: unknown,
201
+ deps: FallbackRpcDeps & { actions?: FallbackActions } = {},
202
+ ): Promise<{ incident: FallbackIncident }> {
203
+ return oneActionAtATime(async () => {
204
+ const home = await homeOf(paseo, deps);
205
+ if (home === null) throw new DashboardError("E_FALLBACK_NOT_FOUND", "paseo-bm cannot find its install home");
206
+ const found = readIncidents(home, deps.log ?? defaultLog).incidents.find((incident) => incident.id === input.incidentId);
207
+ if (found === undefined) throw new DashboardError("E_FALLBACK_NOT_FOUND", `no fallback incident ${input.incidentId}`);
208
+ const scoped = { ...deps, home };
209
+ if (input.action === "resend") {
210
+ // Only a switched Reviewer whose replacement never appeared; the Manager chat already shows `switched`.
211
+ if (found.role !== "reviewer" || found.status !== "switched" || found.replacementId !== null) {
212
+ throw new DashboardError("E_FALLBACK_NOT_PENDING", `incident ${found.id} has nothing to resend`);
213
+ }
214
+ if (deps.actions?.resend === undefined) throw new DashboardError("E_FALLBACK_CREATE_FAILED", "resending is not available in this build");
215
+ return { incident: await deps.actions.resend(found, paseo, scoped) };
216
+ }
217
+ if (found.status !== "pending") throw new DashboardError("E_FALLBACK_NOT_PENDING", `incident ${found.id} is ${found.status}, not pending`);
218
+ let action: FallbackAction;
219
+ if (input.action === "dismiss") action = dismissIncident;
220
+ else if (input.action === "switch") {
221
+ if (deps.actions?.switch === undefined) throw new DashboardError("E_FALLBACK_CREATE_FAILED", "switching is not available in this build");
222
+ action = deps.actions.switch;
223
+ } else {
224
+ if (deps.actions?.wait === undefined) throw new DashboardError("E_FALLBACK_NO_RESET", "waiting for the reset is not available in this build");
225
+ action = deps.actions.wait;
226
+ }
227
+ const incident = await action(found, paseo, scoped);
228
+ await notifyFallback(incident, paseo, deps);
229
+ return { incident };
230
+ });
231
+ }
232
+
233
+ /** A reset this close is waited for rather than switched away from (§4.6, REQ-067 b). */
234
+ export const AUTO_WAIT_WINDOW_MS = 30 * 60 * 1000;
235
+
236
+ /**
237
+ * What the `auto` policy chooses for a new `pending` incident (§4.6): `wait`
238
+ * when the reset is known and at most 30 minutes away (a reset already past
239
+ * counts), else `switch` when there is a candidate, else nothing — the
240
+ * incident stays pending, as with "Ask me".
241
+ */
242
+ export function autoActionOf(incident: FallbackIncident, now: Date): "wait" | "switch" | null {
243
+ const resetsAt = incident.resetsAt === null ? Number.NaN : Date.parse(incident.resetsAt);
244
+ if (!Number.isNaN(resetsAt) && resetsAt - now.getTime() <= AUTO_WAIT_WINDOW_MS) return "wait";
245
+ return incident.candidate === null ? null : "switch";
246
+ }
247
+
248
+ /**
249
+ * Runs the `auto` policy's choice for a new incident through `fallback.act`,
250
+ * so it takes the same lock, the same checks and the same notices as a click.
251
+ * Returns true when an action ran (its notice went out, or the failure's did);
252
+ * false when nothing was chosen and the incident stays pending. Never throws.
253
+ */
254
+ export async function decideAutomatically(
255
+ incident: FallbackIncident,
256
+ paseo: unknown,
257
+ deps: FallbackRpcDeps & { actions: FallbackActions },
258
+ ): Promise<boolean> {
259
+ const log = deps.log ?? defaultLog;
260
+ const action = autoActionOf(incident, (deps.now ?? (() => new Date()))());
261
+ if (action === null) return false;
262
+ try {
263
+ await handleFallbackAct({ incidentId: incident.id, action }, paseo, deps);
264
+ } catch (error) {
265
+ log(`[paseo-bm] the Auto switch policy could not ${action} for incident ${incident.id}: ${reasonOf(error)}`);
266
+ // The card shows what became of it; the chat is told the state it is in now.
267
+ const home = await homeOf(paseo, deps);
268
+ const now = home === null ? undefined : readIncidents(home, log).incidents.find((entry) => entry.id === incident.id);
269
+ await notifyFallback(now ?? incident, paseo, deps);
270
+ }
271
+ return true;
272
+ }
273
+
274
+ /**
275
+ * Registers `fallback.incidents` and `fallback.act`, and tells the Manager
276
+ * chat about every new `pending` incident — after the `auto` policy has run
277
+ * its choice, if the role has it (§4.6), so the chat hears the chosen state
278
+ * once. `onPaseo` hears each handler's SDK handle (the wait timers are set
279
+ * again from it, §4.4.9). Returns the remover of that listener.
280
+ */
281
+ export function registerFallbackRpcs(
282
+ server: PluginServerContext,
283
+ actions: FallbackActions = {},
284
+ options: { onPaseo?: (paseo: unknown) => void } = {},
285
+ ): () => void {
286
+ const seen = (paseo: unknown) => {
287
+ try {
288
+ options.onPaseo?.(paseo);
289
+ } catch {
290
+ // Setting the wait timers again never fails a card action.
291
+ }
292
+ };
293
+ server.handle(fallbackIncidentsRpc, (input, context) => {
294
+ seen(context.paseo);
295
+ return handleFallbackIncidents(input, context.paseo);
296
+ });
297
+ server.handle(fallbackActRpc, (input, context) => {
298
+ seen(context.paseo);
299
+ return handleFallbackAct(input, context.paseo, { actions });
300
+ });
301
+ return onFallbackIncident(async (incident, paseo, context) => {
302
+ if (incident.status !== "pending") return;
303
+ if (context?.policy === "auto" && (await decideAutomatically(incident, paseo, { actions, home: context.home }))) return;
304
+ await notifyFallback(incident, paseo);
305
+ });
306
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Fallback chains: what the user saves for a role that hits its plan limit
3
+ * (delta 20260921 §4.4.1–§4.4.3, REQ-065 f; ADR-008 D2, D4).
4
+ *
5
+ * A chain has a policy (`ask` shows a card, `off` does nothing, `auto` decides
6
+ * at once by the card's rules — phase 2a-18, §4.6) and 0–3 entries. It lives
7
+ * in two places, on purpose:
8
+ *
9
+ * - entry `n` runs on the alias `bm-<role>-fallback-<n>` in Paseo's config,
10
+ * so the `agent.create` hook recognises the role (`roleOfProvider`). Written
11
+ * through `config-writer.ts`, so the same `revision` guards it as a role save;
12
+ * - the entry's model, thinking and mode, and the policy, live in the user's
13
+ * own file `<install home>/role-fallback.json`: user data like
14
+ * `role-extras.json` (F9) — mode 0600, temp file then rename, symlink
15
+ * refused, no hash in `install.json`, never touched by an update, `--prune`
16
+ * or uninstall.
17
+ *
18
+ * Aliases are written first, then the file. A file write that fails after the
19
+ * aliases is reported; the alias left behind is harmless (no entry uses it)
20
+ * and the next save cleans it up. A file that does not parse is never
21
+ * overwritten: the save is refused until the user fixes or deletes it, so a
22
+ * hand-edited `patterns` block is never lost.
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { z } from "zod";
27
+ import { canonicalJson, readRoleConfig, writeRoleConfig, type ConfigPaseo } from "./config-writer";
28
+ import { TRACES_DIR_NAME } from "./install-home";
29
+ import { costOf } from "./model-costs";
30
+ import { asRecord, availableProviders, checkRoleChoice, nonEmpty, reasonOf } from "./role-choices";
31
+ import { installHomeOf } from "./role-extras";
32
+ import { TIMED_OUT, capabilityOf, modesFor, withTimeout, type ProviderCapability } from "./role-mode";
33
+ import { writeStoreFileAtomically } from "./trace-store";
34
+ import { FALLBACK_ROLES, MAX_FALLBACK_ENTRIES, fallbackAlias, fallbackAliasOf, type FallbackRole } from "../shared/fallback";
35
+ import {
36
+ DashboardError,
37
+ fallbackEntryInputSchema,
38
+ type BmRole,
39
+ type FallbackEntryInput,
40
+ type FallbackSettings,
41
+ type RolesSaveFallbackInput,
42
+ } from "../shared/contracts";
43
+
44
+ export const ROLE_FALLBACK_FILE = "role-fallback.json";
45
+
46
+ const chainSchema = z.object({
47
+ policy: z.enum(["ask", "off", "auto"]),
48
+ entries: z.array(fallbackEntryInputSchema).max(MAX_FALLBACK_ENTRIES),
49
+ });
50
+
51
+ /** `role-fallback.json` (§4.4.2). `patterns` is edited by hand only and shared by every role. */
52
+ export const roleFallbackFileSchema = z.object({
53
+ version: z.literal(1),
54
+ roles: z
55
+ .object({ manager: chainSchema.optional(), worker: chainSchema.optional(), reviewer: chainSchema.optional() })
56
+ .default({}),
57
+ patterns: z
58
+ .object({
59
+ L1: z.array(z.string()).optional(),
60
+ L2: z.array(z.string()).optional(),
61
+ L3: z.array(z.string()).optional(),
62
+ L4: z.array(z.string()).optional(),
63
+ L5: z.array(z.string()).optional(),
64
+ })
65
+ .optional(),
66
+ });
67
+
68
+ export type RoleFallbackFile = z.infer<typeof roleFallbackFileSchema>;
69
+ export type FallbackChain = z.infer<typeof chainSchema>;
70
+
71
+ /** A role the file does not mention: ask, with no entry (the card still offers Wait and "I'll handle it"). */
72
+ export const DEFAULT_CHAIN: FallbackChain = { policy: "ask", entries: [] };
73
+
74
+ const ROLE_LABELS: Readonly<Record<BmRole, string>> = { manager: "Manager", worker: "Worker", reviewer: "Reviewer" };
75
+
76
+ const defaultLog = (message: string): void => console.warn(message);
77
+
78
+ export interface RoleFallbackRead {
79
+ /** The file as parsed, or the defaults when it is missing or invalid. */
80
+ file: RoleFallbackFile;
81
+ /** The JSON object as read, so a save keeps every key it does not own; `null` without a valid file. */
82
+ raw: Record<string, unknown> | null;
83
+ /** Why the file could not be used; `null` when it is valid or simply missing. */
84
+ error: string | null;
85
+ }
86
+
87
+ const emptyFile = (): RoleFallbackFile => ({ version: 1, roles: {} });
88
+
89
+ /** Reads `role-fallback.json` in `home`. Never throws: an invalid file costs one log line and reads as the defaults. */
90
+ export function readRoleFallback(home: string, log: (message: string) => void = defaultLog): RoleFallbackRead {
91
+ const path = join(home, ROLE_FALLBACK_FILE);
92
+ const invalid = (reason: string): RoleFallbackRead => {
93
+ log(`[paseo-bm] ${path} is not usable (${reason}); fallback chains use the defaults until it is fixed or deleted.`);
94
+ return { file: emptyFile(), raw: null, error: reason };
95
+ };
96
+ let text: string;
97
+ try {
98
+ text = readFileSync(path, "utf8");
99
+ } catch (error) {
100
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { file: emptyFile(), raw: null, error: null };
101
+ return invalid(reasonOf(error));
102
+ }
103
+ let parsed: unknown;
104
+ try {
105
+ parsed = JSON.parse(text);
106
+ } catch (error) {
107
+ return invalid(`not JSON: ${reasonOf(error)}`);
108
+ }
109
+ const result = roleFallbackFileSchema.safeParse(parsed);
110
+ if (!result.success) {
111
+ const issue = result.error.issues[0];
112
+ return invalid(issue === undefined ? "unexpected content" : `${issue.path.join(".") || "(root)"}: ${issue.message}`);
113
+ }
114
+ return { file: result.data, raw: asRecord(parsed), error: null };
115
+ }
116
+
117
+ /** A role's chain in the file, or the defaults. */
118
+ export function chainOf(file: RoleFallbackFile, role: BmRole): FallbackChain {
119
+ const chain = file.roles[role];
120
+ return chain === undefined ? { ...DEFAULT_CHAIN, entries: [] } : chain;
121
+ }
122
+
123
+ /** True when the file carries at least one detection pattern of its own. */
124
+ function patternsFromFile(file: RoleFallbackFile): boolean {
125
+ return Object.values(file.patterns ?? {}).some((list) => Array.isArray(list) && list.length > 0);
126
+ }
127
+
128
+ /** Writes the file: atomic, 0600, no symlink on the way. Throws `E_ROLE_SETTINGS_WRITE_FAILED`. */
129
+ function writeRoleFallback(home: string, body: Record<string, unknown>): void {
130
+ try {
131
+ writeStoreFileAtomically({ tracesDir: join(home, TRACES_DIR_NAME) }, join(home, ROLE_FALLBACK_FILE), `${JSON.stringify(body, null, 2)}\n`);
132
+ } catch (error) {
133
+ throw new DashboardError("E_ROLE_SETTINGS_WRITE_FAILED", `could not write ${ROLE_FALLBACK_FILE}: ${reasonOf(error)}`, { cause: error });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * A chain as Roles & models shows it: each entry with its alias, the
139
+ * capability of its provider and its listed price. Lookups that fail give
140
+ * `unknown` / `null` and one log line; never throws.
141
+ */
142
+ export async function fallbackSettingsOf(
143
+ role: BmRole,
144
+ chain: FallbackChain,
145
+ fromFile: boolean,
146
+ paseo: unknown,
147
+ log: (message: string) => void = defaultLog,
148
+ ): Promise<FallbackSettings> {
149
+ const capabilities = new Map<string, Promise<ProviderCapability>>();
150
+ const capabilityFor = (provider: string): Promise<ProviderCapability> => {
151
+ let known = capabilities.get(provider);
152
+ if (known === undefined) {
153
+ known = modesFor(paseo, provider, log).then(capabilityOf, () => "unknown" as const);
154
+ capabilities.set(provider, known);
155
+ }
156
+ return known;
157
+ };
158
+ const entries = await Promise.all(
159
+ chain.entries.map(async (entry, index) => ({
160
+ position: index + 1,
161
+ alias: fallbackAlias(role, index + 1),
162
+ baseProvider: entry.baseProvider,
163
+ model: entry.model,
164
+ thinkingOptionId: entry.thinkingOptionId,
165
+ modeId: entry.modeId,
166
+ capability: await capabilityFor(entry.baseProvider),
167
+ cost: await costOf(paseo, entry.baseProvider, entry.model, log).catch(() => null),
168
+ })),
169
+ );
170
+ return { role, policy: chain.policy, entries, patternsFromFile: fromFile };
171
+ }
172
+
173
+ export interface FallbackDeps {
174
+ log?: (message: string) => void;
175
+ /** Home directory for locating the install home (tests pass a temporary one). */
176
+ homedir?: () => string;
177
+ /** The install home itself, when the caller already knows it (`null`: none); otherwise it is looked up. */
178
+ home?: string | null;
179
+ }
180
+
181
+ /** The install home, raced against the lookup budget like every other read; `null` when unknown. Never throws. */
182
+ async function homeOf(paseo: unknown, deps: FallbackDeps): Promise<string | null> {
183
+ if (deps.home !== undefined) return deps.home;
184
+ const found = await withTimeout(installHomeOf(paseo, deps.homedir === undefined ? {} : { homedir: deps.homedir }));
185
+ return found === TIMED_OUT ? null : found;
186
+ }
187
+
188
+ /**
189
+ * The `fallback` of `roles.settings`: the chain of every role in
190
+ * `FALLBACK_ROLES`, or `null` when none is offered. An install home that
191
+ * cannot be confirmed reads as the defaults (a save then reports it); an
192
+ * invalid file adds one warning.
193
+ */
194
+ export async function fallbackForSettings(
195
+ paseo: unknown,
196
+ deps: FallbackDeps = {},
197
+ ): Promise<{ fallback: Partial<Record<FallbackRole, FallbackSettings>> | null; warnings: string[] }> {
198
+ if (FALLBACK_ROLES.length === 0) return { fallback: null, warnings: [] };
199
+ const log = deps.log ?? defaultLog;
200
+ const home = await homeOf(paseo, deps);
201
+ const read = home === null ? { file: emptyFile(), raw: null, error: null } : readRoleFallback(home, log);
202
+ const warnings =
203
+ read.error === null ? [] : [`${ROLE_FALLBACK_FILE} is not valid (${read.error}); fallback uses the defaults until you fix or delete it.`];
204
+ const fromFile = patternsFromFile(read.file);
205
+ const fallback: Partial<Record<FallbackRole, FallbackSettings>> = {};
206
+ for (const role of FALLBACK_ROLES) fallback[role] = await fallbackSettingsOf(role, chainOf(read.file, role), fromFile, paseo, log);
207
+ return { fallback, warnings };
208
+ }
209
+
210
+ let queue: Promise<unknown> = Promise.resolve();
211
+
212
+ /** Runs `work` after every save queued before it; a failure does not block the next one. */
213
+ function serialised<T>(work: () => Promise<T>): Promise<T> {
214
+ const run = queue.then(work, work);
215
+ queue = run.catch(() => undefined);
216
+ return run;
217
+ }
218
+
219
+ /** The alias entry of fallback `n` of `role` (§4.4.1): a Reviewer alias never gets Paseo tools (ADR-006 D3). */
220
+ export function fallbackAliasEntry(role: BmRole, n: number, baseProvider: string): Record<string, unknown> {
221
+ return {
222
+ extends: baseProvider,
223
+ label: `${ROLE_LABELS[role]} (fallback ${n})`,
224
+ ...(role === "reviewer" ? {} : { paseoTools: { enabled: true } }),
225
+ };
226
+ }
227
+
228
+ /** True when `existing` already holds every key of `wanted` with the same value. */
229
+ function holds(existing: unknown, wanted: Record<string, unknown>): boolean {
230
+ const entry = asRecord(existing);
231
+ return entry !== null && Object.entries(wanted).every(([key, value]) => canonicalJson(entry[key]) === canonicalJson(value));
232
+ }
233
+
234
+ /**
235
+ * Handler body of `roles.save-fallback` (§4.4.3). Runs every check before any
236
+ * write (`E_ROLE_SETTINGS_INVALID`): the role's chain is offered; the policy
237
+ * is `ask`, `off` or `auto` (§4.6); at most three entries; each entry passes the checks of a
238
+ * role save (the Reviewer's mode rule included); no two entries with the same
239
+ * provider and model; no entry equal to the role's own provider and model.
240
+ * Then the aliases (revision-checked, `E_ROLE_SETTINGS_CONFLICT` on a stale
241
+ * one) and the file. An entry on the role's own base provider is warned about,
242
+ * never refused.
243
+ */
244
+ export function handleRolesSaveFallback(
245
+ input: RolesSaveFallbackInput,
246
+ paseo: unknown,
247
+ deps: FallbackDeps = {},
248
+ ): Promise<{ revision: string; fallback: FallbackSettings; warnings: string[] }> {
249
+ return serialised(async () => {
250
+ const log = deps.log ?? defaultLog;
251
+ const invalid = (detail: string) => new DashboardError("E_ROLE_SETTINGS_INVALID", detail);
252
+ const role = input.role;
253
+ if (!(FALLBACK_ROLES as readonly string[]).includes(role)) {
254
+ throw invalid(`fallback chains are not offered for the ${ROLE_LABELS[role] ?? String(role)} in this release`);
255
+ }
256
+ if (input.policy !== "ask" && input.policy !== "off" && input.policy !== "auto") throw invalid(`policy "${String(input.policy)}" is not a fallback policy`);
257
+ const entries: FallbackEntryInput[] = Array.isArray(input.entries) ? input.entries : [];
258
+ if (entries.length > MAX_FALLBACK_ENTRIES) throw invalid(`at most ${MAX_FALLBACK_ENTRIES} fallbacks per role, got ${entries.length}`);
259
+
260
+ const available = await availableProviders(paseo);
261
+ for (const entry of entries) await checkRoleChoice(role, entry, paseo, log, available);
262
+ const seen = new Set<string>();
263
+ for (const [index, entry] of entries.entries()) {
264
+ const key = JSON.stringify([entry.baseProvider, entry.model]);
265
+ if (seen.has(key)) throw invalid(`fallback ${index + 1} repeats ${entry.baseProvider} · ${entry.model}`);
266
+ seen.add(key);
267
+ }
268
+
269
+ const { config } = await readRoleConfig(paseo as ConfigPaseo);
270
+ const providers = asRecord(config.providers) ?? {};
271
+ const mainId = `bm-${role}`;
272
+ const mainBase = nonEmpty(asRecord(providers[mainId])?.["extends"]);
273
+ const mainProfile = (Array.isArray(config.agentProfiles) ? config.agentProfiles : []).find((profile) => profile?.["id"] === mainId);
274
+ const mainModel = nonEmpty(mainProfile?.["model"]);
275
+ const warnings: string[] = [];
276
+ for (const [index, entry] of entries.entries()) {
277
+ if (entry.baseProvider !== mainBase) continue;
278
+ if (entry.model === mainModel) throw invalid(`fallback ${index + 1} is the ${ROLE_LABELS[role]} itself (${entry.baseProvider} · ${entry.model})`);
279
+ warnings.push(`Fallback ${index + 1} runs on ${entry.baseProvider} like the ${ROLE_LABELS[role]} itself: it only helps when the limit is per model.`);
280
+ }
281
+
282
+ const home = await homeOf(paseo, deps);
283
+ if (home === null) {
284
+ throw new DashboardError("E_ROLE_SETTINGS_WRITE_FAILED", "paseo-bm cannot find its install home; run npx paseo-bm install, then save again");
285
+ }
286
+ const read = readRoleFallback(home, log);
287
+ if (read.error !== null) throw invalid(`${ROLE_FALLBACK_FILE} is not valid (${read.error}); fix or delete it, then save again`);
288
+
289
+ // Entry n on alias n, renumbered so n stays contiguous; aliases past the
290
+ // new chain are removed. Only what differs is sent.
291
+ const aliasWrites: Record<string, Record<string, unknown>> = {};
292
+ for (const [index, entry] of entries.entries()) {
293
+ const alias = fallbackAlias(role, index + 1);
294
+ const wanted = fallbackAliasEntry(role, index + 1, entry.baseProvider);
295
+ if (!holds(providers[alias], wanted)) aliasWrites[alias] = wanted;
296
+ }
297
+ const removals = Object.keys(providers).filter((id) => {
298
+ const found = fallbackAliasOf(id);
299
+ return found !== null && found.role === role && found.position > entries.length;
300
+ });
301
+ const written = await writeRoleConfig(paseo as ConfigPaseo, {
302
+ expectedRevision: input.revision,
303
+ providers: aliasWrites,
304
+ removeProviders: removals,
305
+ });
306
+
307
+ const chain: FallbackChain = {
308
+ policy: input.policy,
309
+ entries: entries.map((entry) => ({
310
+ baseProvider: entry.baseProvider,
311
+ model: entry.model,
312
+ thinkingOptionId: entry.thinkingOptionId,
313
+ modeId: entry.modeId,
314
+ })),
315
+ };
316
+ const raw = read.raw ?? {};
317
+ writeRoleFallback(home, { ...raw, version: 1, roles: { ...(asRecord(raw["roles"]) ?? {}), [role]: chain } });
318
+
319
+ const fallback = await fallbackSettingsOf(role, chain, patternsFromFile(read.file), paseo, log);
320
+ return { revision: written.revision, fallback, warnings };
321
+ });
322
+ }