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,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
|
+
}
|