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,222 @@
1
+ /**
2
+ * The only path by which the plugin writes Paseo's configuration (delta
3
+ * 20260921 §4.3.4, ADR-008).
4
+ *
5
+ * Paseo's `config.patch` deep-merges `providers` (so one alias can change
6
+ * without touching another) but REPLACES the whole `agentProfiles` array, and
7
+ * carries no revision or condition (proposal S10). So every write here:
8
+ *
9
+ * 1. reads the config and compares its `revision` with the one the caller saw
10
+ * when it showed the user the settings — different means another writer
11
+ * (Paseo's Settings, the installer) changed it meanwhile: nothing is written;
12
+ * 2. builds the patch from EXACTLY the array just read, replacing only the
13
+ * `bm-*` role profile in place (`null` deletes a key, never writes `null`);
14
+ * 3. writes it with ONE `config.patch`;
15
+ * 4. reads it back and checks that the `bm-*` values it wrote are there —
16
+ * otherwise `E_ROLE_SETTINGS_WRITE_FAILED`; it never writes again, since
17
+ * that would replace the array once more;
18
+ * 5. runs under one in-process mutex, so two saves never interleave.
19
+ *
20
+ * Residual risk the owner accepted (Q3 a, Q15 a): a change made in the app
21
+ * exactly between steps 1 and 3 is overwritten and CANNOT be reported — the
22
+ * array written back restores it as read, so the read-back looks unchanged.
23
+ * Steps 1 and 3 only narrow that window to one handler's round trip.
24
+ *
25
+ * Scope (ADR-008 D2): provider ids must start with `bm-`; profiles only
26
+ * `bm-manager`, `bm-worker`, `bm-reviewer`, and only when they already exist —
27
+ * creating a role is the installer's job. The SDK view is flat (design F12):
28
+ * `config.providers` is `agents.providers`, `config.agentProfiles` is
29
+ * `daemon.agentProfiles`. Never calls `paseo daemon reload`, never touches the
30
+ * file itself.
31
+ */
32
+ import { createHash } from "node:crypto";
33
+ import { DashboardError } from "../shared/contracts";
34
+
35
+ /** The part of Paseo's config the role settings live in. */
36
+ export interface RoleConfigView {
37
+ providers?: Record<string, unknown> | null;
38
+ agentProfiles?: ReadonlyArray<Record<string, unknown>> | null;
39
+ }
40
+
41
+ /** The SDK slice this module uses; `PaseoApi` is structurally assignable. */
42
+ export interface ConfigPaseo {
43
+ config: {
44
+ get(): Promise<{ config: RoleConfigView }>;
45
+ patch(patch: Record<string, unknown>): Promise<unknown>;
46
+ };
47
+ }
48
+
49
+ /** The three role profiles the plugin may edit. */
50
+ export const ROLE_PROFILE_IDS: readonly string[] = ["bm-manager", "bm-worker", "bm-reviewer"];
51
+
52
+ const isBmId = (id: string): boolean => id.startsWith("bm-");
53
+
54
+ /** JSON with object keys sorted at every level, so equal content gives equal text. */
55
+ export function canonicalJson(value: unknown): string {
56
+ return JSON.stringify(sortKeys(value));
57
+ }
58
+
59
+ function sortKeys(value: unknown): unknown {
60
+ if (Array.isArray(value)) return value.map(sortKeys);
61
+ if (value !== null && typeof value === "object") {
62
+ const out: Record<string, unknown> = {};
63
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) out[key] = sortKeys((value as Record<string, unknown>)[key]);
64
+ return out;
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /**
70
+ * The revision of the role settings: sha256 of the canonical JSON of every
71
+ * `bm-*` provider and the WHOLE profile array (the whole array, because a
72
+ * write replaces it). Shared with `roles.settings`, which hands it to the client.
73
+ */
74
+ export function roleConfigRevision(config: RoleConfigView): string {
75
+ const providers: Record<string, unknown> = {};
76
+ const all = config.providers;
77
+ if (all !== null && typeof all === "object" && !Array.isArray(all)) {
78
+ for (const [id, entry] of Object.entries(all)) if (isBmId(id)) providers[id] = entry;
79
+ }
80
+ const profiles = Array.isArray(config.agentProfiles) ? config.agentProfiles : [];
81
+ return createHash("sha256").update(canonicalJson({ providers, profiles })).digest("hex");
82
+ }
83
+
84
+ /** Reads the config and its revision. */
85
+ export async function readRoleConfig(paseo: ConfigPaseo): Promise<{ revision: string; config: RoleConfigView }> {
86
+ const { config } = await paseo.config.get();
87
+ return { revision: roleConfigRevision(config ?? {}), config: config ?? {} };
88
+ }
89
+
90
+ /** One write of the role settings. */
91
+ export interface RoleConfigWrite {
92
+ /** The revision the caller showed the user. */
93
+ expectedRevision: string;
94
+ /** `bm-*` provider id → the keys to set (deep-merged by the daemon). */
95
+ providers?: Record<string, Record<string, unknown>>;
96
+ /** `bm-*` provider ids to delete. */
97
+ removeProviders?: readonly string[];
98
+ /** Role profile id → the keys to set; `null` deletes the key. */
99
+ profiles?: Record<string, Record<string, unknown>>;
100
+ }
101
+
102
+ export interface RoleConfigWriteResult {
103
+ revision: string;
104
+ config: RoleConfigView;
105
+ }
106
+
107
+ let queue: Promise<unknown> = Promise.resolve();
108
+
109
+ /** Runs `work` after every write queued before it; a failure does not block the next one. */
110
+ function serialised<T>(work: () => Promise<T>): Promise<T> {
111
+ const run = queue.then(work, work);
112
+ queue = run.catch(() => undefined);
113
+ return run;
114
+ }
115
+
116
+ function invalid(detail: string): DashboardError {
117
+ return new DashboardError("E_ROLE_SETTINGS_INVALID", detail);
118
+ }
119
+
120
+ function checkScope(write: RoleConfigWrite, config: RoleConfigView): void {
121
+ const providers = config.providers ?? {};
122
+ for (const id of [...Object.keys(write.providers ?? {}), ...(write.removeProviders ?? [])]) {
123
+ if (!isBmId(id)) throw invalid(`the plugin only writes bm-* providers, not "${id}"`);
124
+ }
125
+ for (const id of Object.keys(write.providers ?? {})) {
126
+ if (ROLE_PROFILE_IDS.includes(id) && !Object.prototype.hasOwnProperty.call(providers, id)) {
127
+ throw invalid(`provider "${id}" is not registered; run npx paseo-bm install first`);
128
+ }
129
+ }
130
+ for (const id of write.removeProviders ?? []) {
131
+ if (ROLE_PROFILE_IDS.includes(id)) throw invalid(`provider "${id}" belongs to the installer and is never removed by the plugin`);
132
+ }
133
+ const profiles = Array.isArray(config.agentProfiles) ? config.agentProfiles : [];
134
+ for (const id of Object.keys(write.profiles ?? {})) {
135
+ if (!ROLE_PROFILE_IDS.includes(id)) throw invalid(`the plugin only edits the bm-manager, bm-worker and bm-reviewer profiles, not "${id}"`);
136
+ if (!profiles.some((entry) => entry?.id === id)) throw invalid(`profile "${id}" is not registered; run npx paseo-bm install first`);
137
+ }
138
+ }
139
+
140
+ /** The profile array to write: the one just read, with only the named role profiles changed in place. */
141
+ function patchedProfiles(read: ReadonlyArray<Record<string, unknown>>, edits: Record<string, Record<string, unknown>>): Record<string, unknown>[] {
142
+ return read.map((entry) => {
143
+ const id = typeof entry?.id === "string" ? entry.id : null;
144
+ if (id === null || !Object.prototype.hasOwnProperty.call(edits, id)) return entry;
145
+ const next: Record<string, unknown> = { ...entry };
146
+ for (const [key, value] of Object.entries(edits[id]!)) {
147
+ if (value === null || value === undefined) delete next[key];
148
+ else next[key] = value;
149
+ }
150
+ return next;
151
+ });
152
+ }
153
+
154
+ /** True when every key of `wanted` is in `actual` with the same value, objects compared key by key. */
155
+ function contains(actual: unknown, wanted: unknown): boolean {
156
+ if (wanted !== null && typeof wanted === "object" && !Array.isArray(wanted)) {
157
+ if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false;
158
+ return Object.entries(wanted as Record<string, unknown>).every(([key, value]) => contains((actual as Record<string, unknown>)[key], value));
159
+ }
160
+ return canonicalJson(actual) === canonicalJson(wanted);
161
+ }
162
+
163
+ /** What of the write the read-back does not show (Q15 a: the plugin checks its OWN entries). */
164
+ function missingFromReadBack(write: RoleConfigWrite, after: RoleConfigView): string[] {
165
+ const missing: string[] = [];
166
+ const providers = after.providers ?? {};
167
+ for (const [id, entry] of Object.entries(write.providers ?? {})) {
168
+ if (!contains((providers as Record<string, unknown>)[id], entry)) missing.push(`provider ${id}`);
169
+ }
170
+ for (const id of write.removeProviders ?? []) {
171
+ if (Object.prototype.hasOwnProperty.call(providers, id)) missing.push(`removal of provider ${id}`);
172
+ }
173
+ const profiles = Array.isArray(after.agentProfiles) ? after.agentProfiles : [];
174
+ for (const [id, edits] of Object.entries(write.profiles ?? {})) {
175
+ const profile = profiles.find((entry) => entry?.id === id);
176
+ const ok =
177
+ profile !== undefined &&
178
+ Object.entries(edits).every(([key, value]) =>
179
+ value === null || value === undefined ? !Object.prototype.hasOwnProperty.call(profile, key) : contains(profile[key], value),
180
+ );
181
+ if (!ok) missing.push(`profile ${id}`);
182
+ }
183
+ return missing;
184
+ }
185
+
186
+ /**
187
+ * Writes the role settings by the five steps above. Throws a coded
188
+ * `DashboardError`: `E_ROLE_SETTINGS_CONFLICT` (revision changed, nothing
189
+ * written), `E_ROLE_SETTINGS_INVALID` (out of scope), `E_ROLE_SETTINGS_WRITE_FAILED`
190
+ * (the daemon refused the patch; it restores its own file on failure, S10).
191
+ */
192
+ export function writeRoleConfig(paseo: ConfigPaseo, write: RoleConfigWrite): Promise<RoleConfigWriteResult> {
193
+ return serialised(async () => {
194
+ const { revision, config } = await readRoleConfig(paseo);
195
+ if (revision !== write.expectedRevision) {
196
+ throw new DashboardError("E_ROLE_SETTINGS_CONFLICT", "the configuration changed elsewhere; reopen Roles & models");
197
+ }
198
+ checkScope(write, config);
199
+
200
+ const read = Array.isArray(config.agentProfiles) ? config.agentProfiles : [];
201
+ const patch: Record<string, unknown> = {};
202
+ if (write.providers !== undefined && Object.keys(write.providers).length > 0) patch.providers = write.providers;
203
+ if (write.removeProviders !== undefined && write.removeProviders.length > 0) patch.removeProviders = [...write.removeProviders];
204
+ // The array is only ever sent when a role profile changes: every other
205
+ // write leaves `agentProfiles` alone and so cannot overwrite anything.
206
+ if (write.profiles !== undefined && Object.keys(write.profiles).length > 0) patch.agentProfiles = patchedProfiles(read, write.profiles);
207
+ if (Object.keys(patch).length === 0) return { revision, config };
208
+
209
+ try {
210
+ await paseo.config.patch(patch);
211
+ } catch (error) {
212
+ throw new DashboardError("E_ROLE_SETTINGS_WRITE_FAILED", error instanceof Error ? error.message : String(error), { cause: error });
213
+ }
214
+
215
+ const after = await readRoleConfig(paseo);
216
+ const missing = missingFromReadBack(write, after.config);
217
+ if (missing.length > 0) {
218
+ throw new DashboardError("E_ROLE_SETTINGS_WRITE_FAILED", `Paseo did not keep the saved values (${missing.join(", ")})`);
219
+ }
220
+ return { revision: after.revision, config: after.config };
221
+ });
222
+ }
package/server/cost.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Tokens and cost (WP-209, Dashboard Design §9, REQ-052).
3
+ *
4
+ * Three rules, in order:
5
+ *
6
+ * 1. **A cost the provider reported wins.** It is the only figure that is not
7
+ * an estimate, so it is used verbatim and labelled `provider`.
8
+ * 2. **Otherwise estimate from the bundled, dated table**, and label the result
9
+ * `estimated` together with the date of the table. Cache reads are priced
10
+ * with their own rate: charging `cachedInputTokens` at the input rate is the
11
+ * single mistake that would make every estimate several times too high.
12
+ * 3. **Otherwise use the rate Paseo lists for the model** (delta 20260921
13
+ * §4.2.7, REQ-063 g): `model-costs.ts` reads `metadata.cost` from
14
+ * `providers.listModels` for the provider a record names, and the caller
15
+ * hands those rates in as `listed`. Only the bundled table is consulted
16
+ * when nothing is handed in, which is the case for records written without
17
+ * `runtime.provider`.
18
+ * 4. **A model with neither gets no number.** `costBasis` becomes
19
+ * `unavailable` and the UI shows tokens only (REQ-052d). A guessed rate
20
+ * would be worse than an empty cell.
21
+ *
22
+ * There is no network call here and no way to add one: the table is a constant
23
+ * in `shared/prices.ts` (REQ-052e), and the listed rates arrive as a plain map
24
+ * the RPC handler resolved beforehand from the local daemon.
25
+ */
26
+ import { MODEL_PRICES, PRICES_UPDATED_AT, type ModelPrice } from "../shared/prices";
27
+ import type { Usage } from "../shared/contracts";
28
+
29
+ const PER_MILLION = 1_000_000;
30
+
31
+ /**
32
+ * Rates Paseo lists for models the bundled table lacks, keyed by the model id
33
+ * exactly as the usage names it. `updatedAt` is the day the list was read
34
+ * (`YYYY-MM-DD`), shown where the table's own date would be.
35
+ */
36
+ export type ListedPrices = ReadonlyMap<string, { price: ModelPrice; updatedAt: string }>;
37
+
38
+ /**
39
+ * Price row for a model id.
40
+ *
41
+ * Accepts `provider/model` as well as a bare id, because Paseo reports the
42
+ * provider-qualified form in some places (`bm-worker/claude-opus-5`) and the
43
+ * bare model id in others.
44
+ */
45
+ export function priceFor(model: string | null): ModelPrice | null {
46
+ if (model === null || model === "") return null;
47
+ const direct = MODEL_PRICES[model];
48
+ if (direct !== undefined) return direct;
49
+ const slash = model.lastIndexOf("/");
50
+ if (slash === -1) return null;
51
+ return MODEL_PRICES[model.slice(slash + 1)] ?? null;
52
+ }
53
+
54
+ /** Rounds to cents-with-headroom so a displayed estimate is not fake-precise. */
55
+ function roundUsd(value: number): number {
56
+ return Math.round(value * 10_000) / 10_000;
57
+ }
58
+
59
+ /**
60
+ * Applies rules 1 to 4 to a token sum.
61
+ *
62
+ * A usage that already carries `costBasis: "provider"` is returned untouched:
63
+ * re-estimating over a real figure would replace a fact with an approximation.
64
+ * The bundled table always wins over a `listed` rate for the same model.
65
+ */
66
+ export function priceUsage(usage: Usage, listed?: ListedPrices): Usage {
67
+ if (usage.costBasis === "provider" && usage.costUsd !== null) return usage;
68
+
69
+ const bundled = priceFor(usage.model);
70
+ const fromList = bundled === null && usage.model !== null ? listed?.get(usage.model) : undefined;
71
+ const price = bundled ?? fromList?.price ?? null;
72
+ if (price === null) {
73
+ return { ...usage, costUsd: null, costBasis: "unavailable", pricesUpdatedAt: null };
74
+ }
75
+
76
+ const costUsd = roundUsd(
77
+ (usage.inputTokens * price.inputUsdPerMTok +
78
+ usage.cachedInputTokens * price.cacheReadUsdPerMTok +
79
+ usage.outputTokens * price.outputUsdPerMTok) /
80
+ PER_MILLION,
81
+ );
82
+ return {
83
+ ...usage,
84
+ costUsd,
85
+ costBasis: "estimated",
86
+ pricesUpdatedAt: bundled === null ? (fromList?.updatedAt ?? null) : PRICES_UPDATED_AT,
87
+ };
88
+ }