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,913 @@
1
+ /**
2
+ * Setup view of the Beads Manager surface (delta 20260916-setup-screen):
3
+ * additional instructions per role with a full preview, the agent skills with
4
+ * a Test button, and the beads tools `br` / `bv` with a confirmed Install.
5
+ * Above the instructions, "Roles & models" (delta 20260921 §4.3.1) shows each
6
+ * role's provider, model, thinking and mode, with an Edit form that saves
7
+ * through `roles.save-settings`, and under a role whose fallback chain is
8
+ * offered, its policy and entries, saved through `roles.save-fallback` (§4.4.3).
9
+ *
10
+ * It is the surface's MAIN screen (owner decision Q1, delta 20260918e §4.2):
11
+ * no back button, and a "Workspaces" button to the workspace list. The surface
12
+ * hands it the status strip, so what a slash command reported is seen here.
13
+ *
14
+ * Wording lives in `setup-model.ts`. Client rules: React Native primitives
15
+ * only, colours from the theme, no Node import, no `server/` import.
16
+ */
17
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
18
+ import { copyText } from "@getpaseo/plugin/client/react-native";
19
+ import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
20
+ import { useMemo, useState, type ReactNode } from "react";
21
+ import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from "react-native";
22
+ import {
23
+ rolesInstructionsRpc,
24
+ rolesOptionsRpc,
25
+ rolesSaveExtraRpc,
26
+ rolesSaveFallbackRpc,
27
+ rolesSaveSettingsRpc,
28
+ rolesSettingsRpc,
29
+ setupInstallToolRpc,
30
+ setupStatusRpc,
31
+ type BmRole,
32
+ type FallbackEntryInput,
33
+ type FallbackSettings,
34
+ type RoleSetting,
35
+ type RolesOptions,
36
+ type RolesSettings,
37
+ type SetupStatus,
38
+ } from "../shared/contracts";
39
+ import { dashboardStyles, toneColor, type Badge } from "./dashboard-model";
40
+ import { errorMessageOf } from "./launch-manager";
41
+ import { MarkdownView } from "./markdown-view";
42
+ import {
43
+ FALLBACK_POLICY_CHOICES,
44
+ ROLES_APPLY_NOTICE,
45
+ SETUP_ROLES,
46
+ addFallback,
47
+ applySavedRole,
48
+ canAddFallback,
49
+ entryAsSetting,
50
+ entryOfDraft,
51
+ fallbackBlocks,
52
+ fallbackDraftChanged,
53
+ fallbackDraftOf,
54
+ fallbackEntryText,
55
+ fallbackPolicyWarning,
56
+ fallbackPriceText,
57
+ moveFallback,
58
+ removeFallback,
59
+ replaceFallback,
60
+ saveFallbackInput,
61
+ extraCounter,
62
+ installWarning,
63
+ isSettingsConflict,
64
+ providerLabel,
65
+ roleDraftOf,
66
+ roleFormView,
67
+ roleRows,
68
+ rowOptionProviders,
69
+ saveErrorText,
70
+ saveSettingsInput,
71
+ savedNotes,
72
+ setupHeadline,
73
+ paseoToolsWarnings,
74
+ skillChips,
75
+ skillDirsText,
76
+ toolBadge,
77
+ type FallbackDraft,
78
+ type RoleChoice,
79
+ type RoleDraft,
80
+ type SetupRole,
81
+ } from "./setup-model";
82
+ import { Chip, RoleMark, type Styles, type Theme } from "./ui";
83
+ import { PLUGIN_VERSION } from "../shared/version";
84
+
85
+ export interface SetupScreenProps extends PluginSurfaceProps {
86
+ onOpenWorkspaces: () => void;
87
+ /** The surface's status strip (slash-command notice, launch state), drawn under the header. */
88
+ status?: ReactNode;
89
+ }
90
+
91
+ function CopyButton({ text, styles }: { text: string; styles: Styles }) {
92
+ const [copied, setCopied] = useState(false);
93
+ return (
94
+ <Pressable
95
+ accessibilityRole="button"
96
+ onPress={() => {
97
+ void copyText(text).then(
98
+ () => {
99
+ setCopied(true);
100
+ setTimeout(() => setCopied(false), 2000);
101
+ },
102
+ () => setCopied(false),
103
+ );
104
+ }}
105
+ style={styles.secondaryButton}
106
+ >
107
+ <Text style={styles.secondaryButtonText}>{copied ? "Copied" : "Copy"}</Text>
108
+ </Pressable>
109
+ );
110
+ }
111
+
112
+ function CommandLine({ label, command, styles, theme }: { label: string; command: string; styles: Styles; theme: Theme }) {
113
+ return (
114
+ <View style={{ gap: 4 }}>
115
+ <Text style={styles.body}>{label}</Text>
116
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
117
+ <Text
118
+ style={[styles.mono, { flex: 1, backgroundColor: theme.colors.surface0, padding: 6, borderRadius: 6 }]}
119
+ selectable
120
+ >
121
+ {command}
122
+ </Text>
123
+ <CopyButton text={command} styles={styles} />
124
+ </View>
125
+ </View>
126
+ );
127
+ }
128
+
129
+ function RoleCard({ role, label, mark, styles, theme, compact }: {
130
+ role: SetupRole;
131
+ label: string;
132
+ mark: (typeof SETUP_ROLES)[number]["mark"];
133
+ styles: Styles;
134
+ theme: Theme;
135
+ compact: boolean;
136
+ }) {
137
+ const getInstructions = useRpc(rolesInstructionsRpc);
138
+ const saveExtra = useRpc(rolesSaveExtraRpc);
139
+ const queryClient = useQueryClient();
140
+ const instructions = useQuery({
141
+ queryKey: ["paseo-bm", "setup", "instructions", role],
142
+ queryFn: () => getInstructions({ role }),
143
+ });
144
+ const [draft, setDraft] = useState<string | null>(null);
145
+ const [preview, setPreview] = useState(false);
146
+ const [saving, setSaving] = useState(false);
147
+ const [note, setNote] = useState<{ text: string; tone: "success" | "danger" } | null>(null);
148
+
149
+ const data = instructions.data;
150
+ const text = draft ?? data?.extra ?? "";
151
+ const dirty = data !== undefined && draft !== null && draft !== data.extra;
152
+
153
+ const save = async () => {
154
+ setSaving(true);
155
+ setNote(null);
156
+ try {
157
+ await saveExtra({ role, text });
158
+ setDraft(null);
159
+ await queryClient.invalidateQueries({ queryKey: ["paseo-bm", "setup"] });
160
+ setNote({ text: `Saved. ${label} agents created from now on use it.`, tone: "success" });
161
+ } catch (failure) {
162
+ setNote({ text: errorMessageOf(failure), tone: "danger" });
163
+ } finally {
164
+ setSaving(false);
165
+ }
166
+ };
167
+
168
+ return (
169
+ <View style={[styles.card, { gap: 6 }]}>
170
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
171
+ <RoleMark kind={mark} theme={theme} />
172
+ <Text style={[styles.sectionTitle, { flex: 1 }]}>{label}</Text>
173
+ {data === undefined ? null : <Text style={styles.body}>{extraCounter(text.length, data.maxChars)}</Text>}
174
+ </View>
175
+ {instructions.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
176
+ {instructions.isError ? (
177
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(instructions.error)}</Text>
178
+ ) : null}
179
+ <TextInput
180
+ value={text}
181
+ onChangeText={setDraft}
182
+ multiline
183
+ placeholder={`Additional instructions for every new ${label}. They are added after the built-in rules and cannot override them.`}
184
+ placeholderTextColor={theme.colors.foregroundMuted}
185
+ style={[styles.mono, { minHeight: 72, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 8 }]}
186
+ />
187
+ <View style={styles.chipRow}>
188
+ <Pressable
189
+ accessibilityRole="button"
190
+ disabled={!dirty || saving}
191
+ onPress={() => void save()}
192
+ style={[styles.button, !dirty || saving ? { opacity: 0.5 } : null]}
193
+ >
194
+ <Text style={styles.buttonText}>{saving ? "Saving…" : "Save"}</Text>
195
+ </Pressable>
196
+ {dirty ? (
197
+ <Pressable accessibilityRole="button" onPress={() => setDraft(null)} style={styles.secondaryButton}>
198
+ <Text style={styles.secondaryButtonText}>Discard</Text>
199
+ </Pressable>
200
+ ) : null}
201
+ <Pressable accessibilityRole="button" accessibilityState={{ expanded: preview }} onPress={() => setPreview(!preview)} style={styles.secondaryButton}>
202
+ <Text style={styles.secondaryButtonText}>{preview ? "Hide preview" : "Preview full instructions"}</Text>
203
+ </Pressable>
204
+ </View>
205
+ {note === null ? null : <Text style={[styles.body, { color: toneColor(theme, note.tone) }]}>{note.text}</Text>}
206
+ {preview && data !== undefined ? (
207
+ <View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 4 }]}>
208
+ <Text style={styles.body}>
209
+ {dirty ? "Showing the saved version; save to preview your changes." : `Built-in ${label} instructions plus your additions.`}
210
+ </Text>
211
+ <MarkdownView source={data.full} theme={theme} compact={compact} />
212
+ </View>
213
+ ) : null}
214
+ </View>
215
+ );
216
+ }
217
+
218
+ const ROLES_SETTINGS_KEY = ["paseo-bm", "setup", "roles-settings"] as const;
219
+ const roleOptionsKey = (provider: string) => ["paseo-bm", "setup", "role-options", provider] as const;
220
+
221
+ /** One field of the Edit form: a title and a row of chips, the selected one marked. */
222
+ function ChoiceField({ title, choices, selected, onSelect, styles, theme }: {
223
+ title: string;
224
+ choices: RoleChoice[];
225
+ selected: string | null;
226
+ onSelect: (id: string | null) => void;
227
+ styles: Styles;
228
+ theme: Theme;
229
+ }) {
230
+ return (
231
+ <View style={{ gap: 4 }}>
232
+ <Text style={styles.body}>{title}</Text>
233
+ <View style={styles.chipRow}>
234
+ {choices.map((choice) => {
235
+ const on = choice.id === selected;
236
+ return (
237
+ <Chip
238
+ key={choice.id === null ? "(not set)" : `id:${choice.id}`}
239
+ badge={{ text: choice.label, tone: on ? "info" : "muted" }}
240
+ selected={on}
241
+ onPress={() => onSelect(choice.id)}
242
+ styles={styles}
243
+ theme={theme}
244
+ />
245
+ );
246
+ })}
247
+ </View>
248
+ </View>
249
+ );
250
+ }
251
+
252
+ /**
253
+ * The Edit form of one role (design §4.3.1). `revision` is the one the form
254
+ * was opened with: a save against a configuration changed since then is
255
+ * refused with `E_ROLE_SETTINGS_CONFLICT`, and only reopening takes the new one.
256
+ */
257
+ function RoleEditForm({ role, label, setting, available, revision, styles, theme, onClose, onSaved }: {
258
+ role: BmRole;
259
+ label: string;
260
+ setting: RoleSetting;
261
+ available: readonly string[];
262
+ revision: string;
263
+ styles: Styles;
264
+ theme: Theme;
265
+ onClose: () => void;
266
+ onSaved: (notes: Badge[]) => void;
267
+ }) {
268
+ const getOptions = useRpc(rolesOptionsRpc);
269
+ const saveSettings = useRpc(rolesSaveSettingsRpc);
270
+ const queryClient = useQueryClient();
271
+ const [draft, setDraft] = useState<RoleDraft>(() => roleDraftOf(setting));
272
+ const [saving, setSaving] = useState(false);
273
+ const [error, setError] = useState<string | null>(null);
274
+ const options = useQuery({
275
+ queryKey: roleOptionsKey(draft.baseProvider),
276
+ queryFn: () => getOptions({ provider: draft.baseProvider }),
277
+ enabled: draft.baseProvider !== "",
278
+ });
279
+ const view = roleFormView({ role, setting, available, draft, options: options.data });
280
+ const input = saveSettingsInput(revision, role, view.draft);
281
+ const canSave = input !== null && view.blocker === null && view.changed && !saving;
282
+
283
+ const save = async () => {
284
+ if (input === null) return;
285
+ setSaving(true);
286
+ setError(null);
287
+ try {
288
+ const result = await saveSettings(input);
289
+ queryClient.setQueryData<RolesSettings>(ROLES_SETTINGS_KEY, (old) => (old === undefined ? old : applySavedRole(old, result)));
290
+ void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
291
+ setSaving(false);
292
+ onSaved(savedNotes(result));
293
+ } catch (failure) {
294
+ setError(saveErrorText(failure));
295
+ setSaving(false);
296
+ // Show the configuration as it is now; this form keeps its revision, so only reopening saves over it.
297
+ if (isSettingsConflict(failure)) void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
298
+ }
299
+ };
300
+
301
+ return (
302
+ <View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 8 }]}>
303
+ <Text style={styles.sectionTitle}>{`Edit ${label}`}</Text>
304
+ {view.providers.length === 0 ? (
305
+ <Text style={styles.body}>Paseo reports no available provider right now.</Text>
306
+ ) : (
307
+ <ChoiceField
308
+ title="Provider"
309
+ choices={view.providers.map((provider) => ({ id: provider, label: providerLabel(provider) }))}
310
+ selected={draft.baseProvider === "" ? null : draft.baseProvider}
311
+ onSelect={(id) => setDraft((current) => ({ ...current, baseProvider: id ?? "" }))}
312
+ styles={styles}
313
+ theme={theme}
314
+ />
315
+ )}
316
+ {options.isLoading ? <ActivityIndicator color={styles.spinner.color} /> : null}
317
+ {options.isError ? (
318
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(options.error)}</Text>
319
+ ) : null}
320
+ {view.models.length === 0 ? null : (
321
+ <ChoiceField
322
+ title="Model"
323
+ choices={view.models.map((model) => ({ id: model.id, label: model.label }))}
324
+ selected={view.draft.model}
325
+ onSelect={(id) => setDraft((current) => ({ ...current, model: id }))}
326
+ styles={styles}
327
+ theme={theme}
328
+ />
329
+ )}
330
+ {view.price === null ? null : <Text style={styles.body}>{view.price}</Text>}
331
+ {view.thinking.length === 0 ? null : (
332
+ <ChoiceField
333
+ title="Thinking"
334
+ choices={view.thinking}
335
+ selected={view.draft.thinkingOptionId}
336
+ onSelect={(id) => setDraft((current) => ({ ...current, thinkingOptionId: id }))}
337
+ styles={styles}
338
+ theme={theme}
339
+ />
340
+ )}
341
+ {view.modes.length === 0 ? null : (
342
+ <ChoiceField
343
+ title="Mode"
344
+ choices={view.modes}
345
+ selected={view.draft.modeId}
346
+ onSelect={(id) => setDraft((current) => ({ ...current, modeId: id }))}
347
+ styles={styles}
348
+ theme={theme}
349
+ />
350
+ )}
351
+ {view.modeNote === null ? null : <Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{view.modeNote}</Text>}
352
+ {view.blocker === null || options.isError ? null : <Text style={styles.body}>{view.blocker}</Text>}
353
+ <View style={styles.chipRow}>
354
+ <Pressable
355
+ accessibilityRole="button"
356
+ disabled={!canSave}
357
+ onPress={() => void save()}
358
+ style={[styles.button, canSave ? null : { opacity: 0.5 }]}
359
+ >
360
+ <Text style={styles.buttonText}>{saving ? "Saving…" : "Save"}</Text>
361
+ </Pressable>
362
+ <Pressable accessibilityRole="button" disabled={saving} onPress={onClose} style={styles.secondaryButton}>
363
+ <Text style={styles.secondaryButtonText}>Cancel</Text>
364
+ </Pressable>
365
+ </View>
366
+ {error === null ? null : <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{error}</Text>}
367
+ </View>
368
+ );
369
+ }
370
+
371
+ /**
372
+ * The Edit form of one fallback entry: the same fields and rules as a role's
373
+ * form (`roleFormView`), but it hands the entry back to its chain instead of
374
+ * saving; the chain is saved as a whole.
375
+ */
376
+ function FallbackEntryForm({ role, entry, available, styles, theme, onDone, onCancel }: {
377
+ role: BmRole;
378
+ entry: FallbackEntryInput | null;
379
+ available: readonly string[];
380
+ styles: Styles;
381
+ theme: Theme;
382
+ onDone: (entry: FallbackEntryInput) => void;
383
+ onCancel: () => void;
384
+ }) {
385
+ const getOptions = useRpc(rolesOptionsRpc);
386
+ const setting = useMemo(() => entryAsSetting(role, entry), [role, entry]);
387
+ const [draft, setDraft] = useState<RoleDraft>(() => roleDraftOf(setting));
388
+ const options = useQuery({
389
+ queryKey: roleOptionsKey(draft.baseProvider),
390
+ queryFn: () => getOptions({ provider: draft.baseProvider }),
391
+ enabled: draft.baseProvider !== "",
392
+ });
393
+ const view = roleFormView({ role, setting, available, draft, options: options.data });
394
+ const done = view.blocker === null ? entryOfDraft(view.draft) : null;
395
+
396
+ return (
397
+ <View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 8 }]}>
398
+ <Text style={styles.sectionTitle}>{entry === null ? "Add fallback" : "Edit fallback"}</Text>
399
+ {view.providers.length === 0 ? (
400
+ <Text style={styles.body}>Paseo reports no available provider right now.</Text>
401
+ ) : (
402
+ <ChoiceField
403
+ title="Provider"
404
+ choices={view.providers.map((provider) => ({ id: provider, label: providerLabel(provider) }))}
405
+ selected={draft.baseProvider === "" ? null : draft.baseProvider}
406
+ onSelect={(id) => setDraft((current) => ({ ...current, baseProvider: id ?? "" }))}
407
+ styles={styles}
408
+ theme={theme}
409
+ />
410
+ )}
411
+ {options.isLoading ? <ActivityIndicator color={styles.spinner.color} /> : null}
412
+ {options.isError ? (
413
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(options.error)}</Text>
414
+ ) : null}
415
+ {view.models.length === 0 ? null : (
416
+ <ChoiceField
417
+ title="Model"
418
+ choices={view.models.map((model) => ({ id: model.id, label: model.label }))}
419
+ selected={view.draft.model}
420
+ onSelect={(id) => setDraft((current) => ({ ...current, model: id }))}
421
+ styles={styles}
422
+ theme={theme}
423
+ />
424
+ )}
425
+ {view.price === null ? null : <Text style={styles.body}>{view.price}</Text>}
426
+ {view.thinking.length === 0 ? null : (
427
+ <ChoiceField
428
+ title="Thinking"
429
+ choices={view.thinking}
430
+ selected={view.draft.thinkingOptionId}
431
+ onSelect={(id) => setDraft((current) => ({ ...current, thinkingOptionId: id }))}
432
+ styles={styles}
433
+ theme={theme}
434
+ />
435
+ )}
436
+ {view.modes.length === 0 ? null : (
437
+ <ChoiceField
438
+ title="Mode"
439
+ choices={view.modes}
440
+ selected={view.draft.modeId}
441
+ onSelect={(id) => setDraft((current) => ({ ...current, modeId: id }))}
442
+ styles={styles}
443
+ theme={theme}
444
+ />
445
+ )}
446
+ {view.modeNote === null ? null : <Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{view.modeNote}</Text>}
447
+ {view.blocker === null || options.isError ? null : <Text style={styles.body}>{view.blocker}</Text>}
448
+ <View style={styles.chipRow}>
449
+ <Pressable
450
+ accessibilityRole="button"
451
+ disabled={done === null}
452
+ onPress={() => done !== null && onDone(done)}
453
+ style={[styles.button, done === null ? { opacity: 0.5 } : null]}
454
+ >
455
+ <Text style={styles.buttonText}>{entry === null ? "Add" : "Done"}</Text>
456
+ </Pressable>
457
+ <Pressable accessibilityRole="button" onPress={onCancel} style={styles.secondaryButton}>
458
+ <Text style={styles.secondaryButtonText}>Cancel</Text>
459
+ </Pressable>
460
+ </View>
461
+ </View>
462
+ );
463
+ }
464
+
465
+ /** A small text button of an entry row: ↑, ↓, Edit, Remove. */
466
+ function RowButton({ label, accessibilityLabel, disabled, onPress, styles }: {
467
+ label: string;
468
+ accessibilityLabel: string;
469
+ disabled?: boolean;
470
+ onPress: () => void;
471
+ styles: Styles;
472
+ }) {
473
+ return (
474
+ <Pressable
475
+ accessibilityRole="button"
476
+ accessibilityLabel={accessibilityLabel}
477
+ disabled={disabled}
478
+ onPress={onPress}
479
+ style={[styles.secondaryButton, disabled ? { opacity: 0.4 } : null]}
480
+ >
481
+ <Text style={styles.secondaryButtonText}>{label}</Text>
482
+ </Pressable>
483
+ );
484
+ }
485
+
486
+ /**
487
+ * The fallback chain of one role (§4.3.1): the policy, the entries with
488
+ * reorder, edit and remove, and "Add fallback". Edits stay local until Save;
489
+ * the block keeps the `revision` it had when the user started editing, so a
490
+ * save over a configuration changed since then is refused.
491
+ */
492
+ function FallbackBlock({ chain, label, revision, available, optionsOf, styles, theme }: {
493
+ chain: FallbackSettings;
494
+ label: string;
495
+ revision: string;
496
+ available: readonly string[];
497
+ optionsOf: (provider: string) => RolesOptions | undefined;
498
+ styles: Styles;
499
+ theme: Theme;
500
+ }) {
501
+ const saveFallback = useRpc(rolesSaveFallbackRpc);
502
+ const queryClient = useQueryClient();
503
+ const [editing, setEditing] = useState<{ draft: FallbackDraft; revision: string } | null>(null);
504
+ const [form, setForm] = useState<{ index: number | null } | null>(null);
505
+ const [saving, setSaving] = useState(false);
506
+ const [notes, setNotes] = useState<Badge[]>([]);
507
+ const [error, setError] = useState<string | null>(null);
508
+ const draft = editing?.draft ?? fallbackDraftOf(chain);
509
+ const changed = editing !== null && fallbackDraftChanged(chain, editing.draft);
510
+
511
+ const edit = (next: (current: FallbackDraft) => FallbackDraft) => {
512
+ setNotes([]);
513
+ setError(null);
514
+ setEditing((current) => ({ draft: next(current?.draft ?? fallbackDraftOf(chain)), revision: current?.revision ?? revision }));
515
+ };
516
+
517
+ const save = async () => {
518
+ if (editing === null) return;
519
+ setSaving(true);
520
+ setError(null);
521
+ try {
522
+ const result = await saveFallback(saveFallbackInput(editing.revision, chain.role, editing.draft));
523
+ setEditing(null);
524
+ setNotes(savedNotes(result));
525
+ void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
526
+ } catch (failure) {
527
+ setError(saveErrorText(failure));
528
+ if (isSettingsConflict(failure)) void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
529
+ } finally {
530
+ setSaving(false);
531
+ }
532
+ };
533
+
534
+ return (
535
+ <View style={{ gap: 6, paddingLeft: 12 }}>
536
+ <View style={[styles.chipRow, { alignItems: "center" }]}>
537
+ <Text style={styles.body}>On a usage limit:</Text>
538
+ {FALLBACK_POLICY_CHOICES.map((choice) => {
539
+ const on = draft.policy === choice.id;
540
+ return (
541
+ <Chip
542
+ key={choice.id}
543
+ badge={{ text: choice.label, tone: on ? "info" : "muted" }}
544
+ selected={on}
545
+ onPress={() => edit((current) => ({ ...current, policy: choice.id }))}
546
+ styles={styles}
547
+ theme={theme}
548
+ />
549
+ );
550
+ })}
551
+ </View>
552
+ {fallbackPolicyWarning(chain.role, draft.policy) === null ? null : (
553
+ <Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{fallbackPolicyWarning(chain.role, draft.policy)}</Text>
554
+ )}
555
+ {draft.entries.map((entry, index) => {
556
+ const saved = editing === null ? chain.entries[index] : undefined;
557
+ const price = saved === undefined ? null : fallbackPriceText(saved);
558
+ return (
559
+ <View key={`${index}:${entry.baseProvider}:${entry.model}`} style={{ gap: 4 }}>
560
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
561
+ <Text style={styles.body}>{`Fallback ${index + 1}`}</Text>
562
+ <Text style={[styles.body, { flex: 1, minWidth: 160 }]} selectable>
563
+ {fallbackEntryText(entry, optionsOf(entry.baseProvider))}
564
+ </Text>
565
+ <RowButton label="↑" accessibilityLabel={`Move ${label} fallback ${index + 1} up`} disabled={index === 0} onPress={() => edit((current) => moveFallback(current, index, -1))} styles={styles} />
566
+ <RowButton
567
+ label="↓"
568
+ accessibilityLabel={`Move ${label} fallback ${index + 1} down`}
569
+ disabled={index === draft.entries.length - 1}
570
+ onPress={() => edit((current) => moveFallback(current, index, 1))}
571
+ styles={styles}
572
+ />
573
+ <RowButton label="Edit" accessibilityLabel={`Edit ${label} fallback ${index + 1}`} onPress={() => setForm({ index })} styles={styles} />
574
+ <RowButton label="Remove" accessibilityLabel={`Remove ${label} fallback ${index + 1}`} onPress={() => edit((current) => removeFallback(current, index))} styles={styles} />
575
+ </View>
576
+ {price === null ? null : <Text style={styles.body}>{price}</Text>}
577
+ {form?.index === index ? (
578
+ <FallbackEntryForm
579
+ role={chain.role}
580
+ entry={entry}
581
+ available={available}
582
+ styles={styles}
583
+ theme={theme}
584
+ onDone={(next) => {
585
+ edit((current) => replaceFallback(current, index, next));
586
+ setForm(null);
587
+ }}
588
+ onCancel={() => setForm(null)}
589
+ />
590
+ ) : null}
591
+ </View>
592
+ );
593
+ })}
594
+ {form !== null && form.index === null ? (
595
+ <FallbackEntryForm
596
+ role={chain.role}
597
+ entry={null}
598
+ available={available}
599
+ styles={styles}
600
+ theme={theme}
601
+ onDone={(next) => {
602
+ edit((current) => addFallback(current, next));
603
+ setForm(null);
604
+ }}
605
+ onCancel={() => setForm(null)}
606
+ />
607
+ ) : canAddFallback(draft) ? (
608
+ <View style={styles.chipRow}>
609
+ <RowButton label="+ Add fallback" accessibilityLabel={`Add a fallback for the ${label}`} onPress={() => setForm({ index: null })} styles={styles} />
610
+ </View>
611
+ ) : null}
612
+ {editing === null ? null : (
613
+ <View style={styles.chipRow}>
614
+ <Pressable
615
+ accessibilityRole="button"
616
+ disabled={!changed || saving}
617
+ onPress={() => void save()}
618
+ style={[styles.button, changed && !saving ? null : { opacity: 0.5 }]}
619
+ >
620
+ <Text style={styles.buttonText}>{saving ? "Saving…" : "Save fallbacks"}</Text>
621
+ </Pressable>
622
+ <Pressable
623
+ accessibilityRole="button"
624
+ disabled={saving}
625
+ onPress={() => {
626
+ setEditing(null);
627
+ setForm(null);
628
+ setError(null);
629
+ }}
630
+ style={styles.secondaryButton}
631
+ >
632
+ <Text style={styles.secondaryButtonText}>Discard</Text>
633
+ </Pressable>
634
+ </View>
635
+ )}
636
+ {notes.map((line, index) => (
637
+ <Text key={`${index}:${line.text}`} style={[styles.body, { color: toneColor(theme, line.tone) }]}>
638
+ {line.text}
639
+ </Text>
640
+ ))}
641
+ {error === null ? null : <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{error}</Text>}
642
+ </View>
643
+ );
644
+ }
645
+
646
+ /** "Roles & models": one row per role in the order `roles.settings` returns, each with an Edit form. */
647
+ function RolesSection({ styles, theme }: { styles: Styles; theme: Theme }) {
648
+ const getSettings = useRpc(rolesSettingsRpc);
649
+ const getOptions = useRpc(rolesOptionsRpc);
650
+ const settings = useQuery({ queryKey: ROLES_SETTINGS_KEY, queryFn: () => getSettings({}) });
651
+ const data = settings.data;
652
+ const providers = data === undefined ? [] : rowOptionProviders(data);
653
+ const optionQueries = useQueries({
654
+ queries: providers.map((provider) => ({ queryKey: roleOptionsKey(provider), queryFn: () => getOptions({ provider }) })),
655
+ });
656
+ const optionsOf = (provider: string) => optionQueries[providers.indexOf(provider)]?.data;
657
+ const rows = data === undefined ? [] : roleRows(data, optionsOf);
658
+ const chains = new Map((data === undefined ? [] : fallbackBlocks(data)).map((chain) => [chain.role, chain] as const));
659
+ const [editing, setEditing] = useState<{ role: BmRole; revision: string } | null>(null);
660
+ const [notes, setNotes] = useState<{ role: BmRole; lines: Badge[] } | null>(null);
661
+
662
+ return (
663
+ <>
664
+ <Text style={styles.sectionTitle}>Roles & models</Text>
665
+ {settings.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
666
+ {settings.isError ? (
667
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(settings.error)}</Text>
668
+ ) : null}
669
+ {data === undefined ? null : (
670
+ <View style={[styles.card, { gap: 10 }]}>
671
+ {rows.map((row) => {
672
+ const open = editing?.role === row.role;
673
+ return (
674
+ <View key={row.role} style={{ gap: 6 }}>
675
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
676
+ <RoleMark kind={row.mark} theme={theme} />
677
+ <Text style={styles.sectionTitle}>{row.label}</Text>
678
+ <Text style={[styles.body, { flex: 1, minWidth: 160 }]} selectable>
679
+ {row.text}
680
+ </Text>
681
+ <Pressable
682
+ accessibilityRole="button"
683
+ accessibilityLabel={open ? `Close the ${row.label} form` : `Edit the ${row.label} provider, model, thinking and mode`}
684
+ accessibilityState={{ expanded: open }}
685
+ onPress={() => {
686
+ setNotes(null);
687
+ setEditing(open ? null : { role: row.role, revision: data.revision });
688
+ }}
689
+ style={styles.secondaryButton}
690
+ >
691
+ <Text style={styles.secondaryButtonText}>{open ? "Close" : "Edit"}</Text>
692
+ </Pressable>
693
+ </View>
694
+ {notes?.role === row.role
695
+ ? notes.lines.map((line, index) => (
696
+ <Text key={`${index}:${line.text}`} style={[styles.body, { color: toneColor(theme, line.tone) }]}>
697
+ {line.text}
698
+ </Text>
699
+ ))
700
+ : null}
701
+ {chains.get(row.role) === undefined ? null : (
702
+ <FallbackBlock
703
+ chain={chains.get(row.role)!}
704
+ label={row.label}
705
+ revision={data.revision}
706
+ available={data.providers}
707
+ optionsOf={optionsOf}
708
+ styles={styles}
709
+ theme={theme}
710
+ />
711
+ )}
712
+ {open ? (
713
+ <RoleEditForm
714
+ role={row.role}
715
+ label={row.label}
716
+ setting={row.setting}
717
+ available={data.providers}
718
+ revision={editing.revision}
719
+ styles={styles}
720
+ theme={theme}
721
+ onClose={() => setEditing(null)}
722
+ onSaved={(lines) => {
723
+ setEditing(null);
724
+ setNotes({ role: row.role, lines });
725
+ }}
726
+ />
727
+ ) : null}
728
+ </View>
729
+ );
730
+ })}
731
+ </View>
732
+ )}
733
+ {data?.warnings.map((warning) => (
734
+ <Text key={warning} style={[styles.body, { color: toneColor(theme, "warning") }]}>
735
+ {warning}
736
+ </Text>
737
+ ))}
738
+ <Text style={styles.body}>{ROLES_APPLY_NOTICE}</Text>
739
+ </>
740
+ );
741
+ }
742
+
743
+ function ToolCard({ tool, styles, theme, onInstalled }: {
744
+ tool: SetupStatus["tools"][number];
745
+ styles: Styles;
746
+ theme: Theme;
747
+ onInstalled: () => void;
748
+ }) {
749
+ const install = useRpc(setupInstallToolRpc);
750
+ const [confirming, setConfirming] = useState(false);
751
+ const [busy, setBusy] = useState(false);
752
+ const [result, setResult] = useState<{ text: string; tone: "success" | "danger"; tail: string[] } | null>(null);
753
+ const badge = toolBadge(tool);
754
+ const installable = tool.path === null && tool.installCommand !== null && (tool.id === "br" || tool.id === "bv");
755
+
756
+ const run = async () => {
757
+ if (tool.id === "bd") return;
758
+ setBusy(true);
759
+ setResult(null);
760
+ try {
761
+ const done = await install({ tool: tool.id, confirmed: true });
762
+ setResult({ text: `Installed with \`${done.command}\`.`, tone: "success", tail: done.tail });
763
+ onInstalled();
764
+ } catch (failure) {
765
+ setResult({ text: errorMessageOf(failure), tone: "danger", tail: [] });
766
+ } finally {
767
+ setBusy(false);
768
+ setConfirming(false);
769
+ }
770
+ };
771
+
772
+ return (
773
+ <View style={[styles.card, { gap: 6 }]}>
774
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
775
+ <Text style={[styles.sectionTitle, { flex: 1 }]}>{tool.name}</Text>
776
+ <Chip badge={badge} styles={styles} theme={theme} />
777
+ </View>
778
+ <Text style={styles.body}>{tool.purpose}</Text>
779
+ <Text style={styles.body} selectable>
780
+ {tool.path === null ? "Not found on the daemon's PATH or the usual install folders." : `Found at ${tool.path}`}
781
+ </Text>
782
+ {installable ? (
783
+ confirming ? (
784
+ <View style={{ gap: 6 }}>
785
+ <Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{installWarning(tool)}</Text>
786
+ <View style={styles.chipRow}>
787
+ <Pressable accessibilityRole="button" disabled={busy} onPress={() => void run()} style={styles.button}>
788
+ <Text style={styles.buttonText}>{busy ? "Installing… (up to 5 minutes)" : `Install ${tool.id}`}</Text>
789
+ </Pressable>
790
+ {busy ? null : (
791
+ <Pressable accessibilityRole="button" onPress={() => setConfirming(false)} style={styles.secondaryButton}>
792
+ <Text style={styles.secondaryButtonText}>Cancel</Text>
793
+ </Pressable>
794
+ )}
795
+ </View>
796
+ </View>
797
+ ) : (
798
+ <View style={{ gap: 6 }}>
799
+ <CommandLine label="Install command" command={tool.installCommand!} styles={styles} theme={theme} />
800
+ <Pressable accessibilityRole="button" onPress={() => setConfirming(true)} style={[styles.button, { alignSelf: "flex-start" }]}>
801
+ <Text style={styles.buttonText}>{`Install ${tool.id}…`}</Text>
802
+ </Pressable>
803
+ </View>
804
+ )
805
+ ) : tool.updateCommand !== null ? (
806
+ <CommandLine label="Update it yourself with" command={tool.updateCommand} styles={styles} theme={theme} />
807
+ ) : null}
808
+ {result === null ? null : (
809
+ <View style={{ gap: 2 }}>
810
+ <Text style={[styles.body, { color: toneColor(theme, result.tone) }]}>{result.text}</Text>
811
+ {result.tail.length === 0 ? null : (
812
+ <Text style={[styles.mono, { backgroundColor: theme.colors.surface0, padding: 6, borderRadius: 6 }]} selectable>
813
+ {result.tail.join("\n")}
814
+ </Text>
815
+ )}
816
+ </View>
817
+ )}
818
+ <Text style={[styles.body, { fontSize: 11 }]} selectable>
819
+ {tool.homepage}
820
+ </Text>
821
+ </View>
822
+ );
823
+ }
824
+
825
+ export function SetupScreen({ theme, layout, onOpenWorkspaces, status: statusStrip }: SetupScreenProps) {
826
+ const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
827
+ const getStatus = useRpc(setupStatusRpc);
828
+ const status = useQuery({ queryKey: ["paseo-bm", "setup", "status"], queryFn: () => getStatus({}) });
829
+ const data = status.data;
830
+ const headline = data === undefined ? null : setupHeadline(data);
831
+
832
+ return (
833
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
834
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
835
+ <Text style={[styles.title, { flex: 1 }]} numberOfLines={1}>
836
+ Beads Manager
837
+ </Text>
838
+ <Pressable
839
+ accessibilityRole="button"
840
+ accessibilityLabel="Open the workspace list: Beads Manager, metrics and beads of each workspace"
841
+ onPress={onOpenWorkspaces}
842
+ style={styles.secondaryButton}
843
+ >
844
+ <Text style={styles.secondaryButtonText}>Workspaces</Text>
845
+ </Pressable>
846
+ </View>
847
+ <Text style={styles.body}>Setup for this machine: beads tools, agent skills, and each role&apos;s model and extra instructions.</Text>
848
+ {statusStrip}
849
+ {status.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
850
+ {status.isError ? (
851
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(status.error)}</Text>
852
+ ) : null}
853
+ {headline === null ? null : <Text style={[styles.sectionTitle, { color: toneColor(theme, headline.tone) }]}>{headline.text}</Text>}
854
+ {data === undefined
855
+ ? null
856
+ : paseoToolsWarnings(data).map((warning) => (
857
+ <Text key={warning} style={[styles.body, { color: toneColor(theme, "warning") }]}>
858
+ {warning}
859
+ </Text>
860
+ ))}
861
+
862
+ {/* 1. Beads tools */}
863
+ <Text style={styles.sectionTitle}>Beads tools</Text>
864
+ {data?.tools.map((tool) => (
865
+ <ToolCard key={tool.id} tool={tool} styles={styles} theme={theme} onInstalled={() => void status.refetch()} />
866
+ ))}
867
+ {data === undefined ? null : (
868
+ <Text style={[styles.body, { fontSize: 11 }]}>{`Newest versions as of ${data.latestCheckedOn}; paseo-bm does not look them up online.`}</Text>
869
+ )}
870
+
871
+ {/* 2. Agent skills */}
872
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
873
+ <Text style={[styles.sectionTitle, { flex: 1 }]}>Agent skills</Text>
874
+ <Pressable accessibilityRole="button" disabled={status.isFetching} onPress={() => void status.refetch()} style={styles.secondaryButton}>
875
+ <Text style={styles.secondaryButtonText}>{status.isFetching ? "Testing…" : "Test"}</Text>
876
+ </Pressable>
877
+ </View>
878
+ {data === undefined ? null : (
879
+ <View style={[styles.card, { gap: 6 }]}>
880
+ <Text style={styles.body}>{`Checked ${new Date(data.skills.checkedAt).toLocaleTimeString()} · ${skillDirsText(data.skills.dirs)}`}</Text>
881
+ {data.skills.skills.map((skill) => (
882
+ <View key={skill.name} style={{ gap: 2 }}>
883
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
884
+ <Text style={[styles.mono, { flex: 1 }]}>{`${skill.name}${skill.required ? "" : " (optional)"}`}</Text>
885
+ {skillChips(skill).map((badge) => (
886
+ <Chip key={badge.text} badge={badge} styles={styles} theme={theme} />
887
+ ))}
888
+ </View>
889
+ {skill.problem === null ? null : (
890
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{skill.problem}</Text>
891
+ )}
892
+ </View>
893
+ ))}
894
+ <CommandLine label="Install the required skills for Claude Code and Codex (run it yourself, or use `npx paseo-bm install --apply --install-skills`)" command={data.skills.installCommand} styles={styles} theme={theme} />
895
+ </View>
896
+ )}
897
+
898
+ {/* 3. Roles & models (delta 20260921 §4.3.1) */}
899
+ <RolesSection styles={styles} theme={theme} />
900
+
901
+ {/* 4. Additional instructions per role */}
902
+ <Text style={styles.sectionTitle}>Additional instructions</Text>
903
+ <Text style={styles.body}>
904
+ Added after each role&apos;s built-in instructions, for agents created from now on. Running agents keep what they started with.
905
+ </Text>
906
+ {SETUP_ROLES.map((entry) => (
907
+ <RoleCard key={entry.role} {...entry} styles={styles} theme={theme} compact={layout.compact} />
908
+ ))}
909
+
910
+ <Text style={[styles.body, { fontSize: 11 }]}>{`paseo-bm ${PLUGIN_VERSION}`}</Text>
911
+ </ScrollView>
912
+ );
913
+ }