@frockbot/plugin-shell 0.0.0 → 0.1.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 (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,2346 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ import {
4
+ decodeExternalAuthorizationUrl,
5
+ type AgentTransport,
6
+ type ClientAnnouncement,
7
+ type ClientNotificationIntent,
8
+ type ClientPlugin,
9
+ type ClientRun,
10
+ type ClientStartConnectionResult,
11
+ type ClientTurnEvent,
12
+ } from "@frockbot/client-core";
13
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
14
+ // Connection mutations use the provider-neutral hosted command contract.
15
+ import type {
16
+ ConnectionCommandReceiptV1,
17
+ ConnectionCommandV1,
18
+ } from "@frockbot/connection-core";
19
+ import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
20
+ import { decodeSendToUserPayloadV1 } from "@frockbot/kernel-contracts";
21
+ import { createClientSurfaceRegistry } from "@frockbot/client-ui";
22
+ import type {
23
+ BotNameProvenanceV1,
24
+ BotNotificationPolicy,
25
+ BotProfile,
26
+ BotProfilePatchV1,
27
+ BotSettingsViewV1,
28
+ ConfigurationCommandV1,
29
+ JsonValue,
30
+ ModelAssignment,
31
+ OperationReceiptV1,
32
+ UserSettingsViewV1,
33
+ } from "@frockbot/configuration-core";
34
+ import {
35
+ decodeCatalogEntryV1,
36
+ decodeCatalogIndexV1,
37
+ type CatalogEntryV1,
38
+ type CatalogIndexEntryV1,
39
+ } from "@frockbot/catalog-core";
40
+ import type { SkillRefV1 } from "@frockbot/kernel-contracts";
41
+ import {
42
+ decodeMcpLifecycleReceiptV1,
43
+ decodeMcpServerStatusViewV1,
44
+ } from "@frockbot/plugin-mcp/records";
45
+ import {
46
+ MCP_CONNECTIONS_ROUTE,
47
+ MCP_SERVERS_ROUTE,
48
+ } from "@frockbot/plugin-mcp/backend";
49
+ import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
50
+ import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
51
+ import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
52
+ import {
53
+ decodeApprovalDecisionReceiptV1,
54
+ decodeApprovalListViewV1,
55
+ } from "../approvals.js";
56
+ import {
57
+ decodeTaskListViewV1,
58
+ decodeTaskViewV1,
59
+ } from "@frockbot/plugin-subagents/shared";
60
+ import { ref } from "vue";
61
+ import {
62
+ frockBotWebDataKey,
63
+ type FrockBotWebData,
64
+ type PluginCatalogItem,
65
+ type SendPromptResult,
66
+ type WebActiveRun,
67
+ type WebChatMessage,
68
+ type WebSendPayload,
69
+ type WebTaskChip,
70
+ type WebToolActivity,
71
+ } from "../shared.js";
72
+ import FrockBotApp from "./FrockBotApp.vue";
73
+ import { modelRuntimeLabel } from "./model-presentation.js";
74
+ import { showClientNotificationV1 } from "./notify.js";
75
+ import "@frockbot/client-core/fonts.css";
76
+ import "./styles.css";
77
+
78
+ function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
79
+ const tools = new Map<string, WebToolActivity>();
80
+ for (const event of events) {
81
+ if (event.type === "tool/call" && event.call) {
82
+ tools.set(event.call.id, {
83
+ id: event.call.id,
84
+ name: event.call.name,
85
+ status: "running",
86
+ });
87
+ }
88
+ if (event.type === "tool/result" && event.callId) {
89
+ const tool = tools.get(event.callId);
90
+ if (tool) {
91
+ tool.status = event.isError ? "failed" : "completed";
92
+ tool.text = event.content;
93
+ if (event.attachments && event.attachments.length > 0) {
94
+ tool.attachments = event.attachments.map((attachment) => ({
95
+ kind: attachment.kind,
96
+ mediaType: attachment.mediaType,
97
+ contentHash: attachment.contentHash,
98
+ path: attachment.path,
99
+ }));
100
+ }
101
+ }
102
+ }
103
+ }
104
+ return [...tools.values()];
105
+ }
106
+
107
+ /**
108
+ * The Turn's sends, decoded for the thread. A payload this client cannot
109
+ * decode becomes an `unsupported` entry: a run has to render on a client older
110
+ * than the Bot that produced it, so an unknown shape is a line in the
111
+ * conversation and never an exception.
112
+ */
113
+ function sendsFrom(events: ClientTurnEvent[]): WebSendPayload[] {
114
+ const sends: WebSendPayload[] = [];
115
+ for (const event of events) {
116
+ if (event.type !== "send/to-user") continue;
117
+ try {
118
+ sends.push({
119
+ kind: "payload",
120
+ payload: decodeSendToUserPayloadV1(event.payload),
121
+ });
122
+ } catch {
123
+ sends.push({ kind: "unsupported" });
124
+ }
125
+ }
126
+ return sends;
127
+ }
128
+
129
+ /**
130
+ * The subagents this Turn dispatched, as chips. An event missing a field is
131
+ * skipped rather than drawn half-formed: a run has to render on a client older
132
+ * than the Bot that produced it.
133
+ */
134
+ function tasksFrom(events: ClientTurnEvent[]): WebTaskChip[] {
135
+ const tasks: WebTaskChip[] = [];
136
+ for (const event of events) {
137
+ if (event.type !== "task/dispatched") continue;
138
+ if (
139
+ event.taskId === undefined ||
140
+ event.taskType === undefined ||
141
+ event.description === undefined ||
142
+ event.model === undefined
143
+ ) {
144
+ continue;
145
+ }
146
+ tasks.push({
147
+ taskId: event.taskId,
148
+ taskType: event.taskType,
149
+ description: event.description,
150
+ model: event.model,
151
+ background: event.background !== false,
152
+ });
153
+ }
154
+ return tasks;
155
+ }
156
+
157
+ type DurableRunProjectionState = Pick<
158
+ FrockBotWebData,
159
+ "messages" | "activeRunId" | "activeRun" | "error"
160
+ >;
161
+
162
+ /**
163
+ * The banner exists for Turns the User has to act on. A running Turn is shown
164
+ * by the animated Bot avatar in the thread, so it produces no banner.
165
+ */
166
+ function activeRunView(run: ClientRun): WebActiveRun | undefined {
167
+ if (run.status === "running") {
168
+ // A plain running Turn is shown by the animated Bot avatar, so it has no
169
+ // banner. Once a durable Stop has been accepted the User is waiting on a
170
+ // settlement they asked for, and the banner reports it.
171
+ if (!run.stopRequestedAt) return undefined;
172
+ return {
173
+ runId: run.runId,
174
+ status: run.status,
175
+ message: "Stop accepted; waiting for durable settlement.",
176
+ canResume: false,
177
+ };
178
+ }
179
+ if (run.status === "reconciliation-required") {
180
+ return {
181
+ runId: run.runId,
182
+ status: run.status,
183
+ message: run.stopRequestedAt
184
+ ? "Stop accepted; reconciling the provider outcome before cancelling."
185
+ : (run.recovery?.message ??
186
+ run.failure ??
187
+ "This Turn requires provider reconciliation before it can continue."),
188
+ canResume: !run.stopRequestedAt && run.recovery?.action === "resume",
189
+ };
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ function isTerminalRun(run: ClientRun): boolean {
195
+ return (
196
+ run.status === "completed" ||
197
+ run.status === "failed" ||
198
+ run.status === "cancelled"
199
+ );
200
+ }
201
+
202
+ function assistantMessage(
203
+ run: ClientRun,
204
+ notification: ClientNotificationIntent | undefined,
205
+ ): WebChatMessage {
206
+ if (run.status === "running") {
207
+ // A streaming Turn carries only the text the model has produced. Until
208
+ // there is any, the thread shows the animated avatar and no bubble.
209
+ return {
210
+ id: `${run.runId}:assistant`,
211
+ runId: run.runId,
212
+ role: "assistant",
213
+ text: run.responseText ?? "",
214
+ status: "streaming",
215
+ tools: toolsFrom(run.events),
216
+ sends: sendsFrom(run.events),
217
+ tasks: tasksFrom(run.events),
218
+ };
219
+ }
220
+ if (run.status === "reconciliation-required") {
221
+ return {
222
+ id: `${run.runId}:assistant`,
223
+ runId: run.runId,
224
+ role: "assistant",
225
+ text:
226
+ run.recovery?.message ??
227
+ run.failure ??
228
+ "Provider reconciliation is required before this Turn can continue.",
229
+ status: "reconciliation-required",
230
+ tools: toolsFrom(run.events),
231
+ sends: sendsFrom(run.events),
232
+ tasks: tasksFrom(run.events),
233
+ };
234
+ }
235
+ if (run.status === "cancelled") {
236
+ return {
237
+ id: `${run.runId}:assistant`,
238
+ runId: run.runId,
239
+ role: "assistant",
240
+ text: run.failure ?? "Stopped by an authenticated Stop command.",
241
+ status: "aborted",
242
+ tools: toolsFrom(run.events),
243
+ sends: sendsFrom(run.events),
244
+ tasks: tasksFrom(run.events),
245
+ };
246
+ }
247
+ return {
248
+ id: `${run.runId}:assistant`,
249
+ runId: run.runId,
250
+ role: "assistant",
251
+ text:
252
+ run.status === "failed"
253
+ ? (run.failure ?? "Agent request failed.")
254
+ : (run.responseText ?? notification?.body ?? ""),
255
+ status: run.status === "failed" ? "error" : "completed",
256
+ tools: toolsFrom(run.events),
257
+ sends: sendsFrom(run.events),
258
+ tasks: tasksFrom(run.events),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Projects the Session's announcements as system lines. They carry an `at`
264
+ * timestamp so the thread can place them among the Turns they happened
265
+ * between, and they replace rather than duplicate on every reload.
266
+ */
267
+ export function projectAnnouncements(
268
+ messages: WebChatMessage[],
269
+ announcements: readonly ClientAnnouncement[],
270
+ ): void {
271
+ for (const announcement of announcements) {
272
+ const message: WebChatMessage = {
273
+ id: announcement.announcementId,
274
+ runId: announcement.announcementId,
275
+ role: "system",
276
+ text: `Renamed to ${announcement.to} by ${announcement.namedBy}`,
277
+ at: announcement.at,
278
+ status: "completed",
279
+ tools: [],
280
+ sends: [],
281
+ };
282
+ const index = messages.findIndex(
283
+ (candidate) => candidate.id === announcement.announcementId,
284
+ );
285
+ if (index >= 0) messages[index] = message;
286
+ else messages.push(message);
287
+ }
288
+ }
289
+
290
+ export function projectDurableRuns(
291
+ state: DurableRunProjectionState,
292
+ notifications: readonly ClientNotificationIntent[],
293
+ runs: readonly ClientRun[],
294
+ ): Set<string> {
295
+ const projected = new Set<string>();
296
+ const observedRunId = state.activeRunId;
297
+ let activeRun: WebActiveRun | undefined;
298
+ // Busy state and the banner are separate: a running Turn keeps the composer
299
+ // busy without producing a banner of its own.
300
+ let busyRunId: string | undefined;
301
+ for (const run of runs) {
302
+ const notification = notifications.find(
303
+ (candidate) => candidate.runId === run.runId,
304
+ );
305
+ const userIndex = state.messages.findIndex(
306
+ (message) => message.runId === run.runId && message.role === "user",
307
+ );
308
+ const existingUser = userIndex >= 0 ? state.messages[userIndex] : undefined;
309
+ const user: WebChatMessage = {
310
+ id: `${run.runId}:user`,
311
+ runId: run.runId,
312
+ role: "user",
313
+ text: run.input,
314
+ ...(run.admittedAt
315
+ ? { at: run.admittedAt }
316
+ : existingUser?.at
317
+ ? { at: existingUser.at }
318
+ : {}),
319
+ status: "completed",
320
+ tools: [],
321
+ sends: [],
322
+ };
323
+ if (userIndex >= 0) state.messages[userIndex] = user;
324
+ else state.messages.push(user);
325
+
326
+ const assistantIndex = state.messages.findIndex(
327
+ (message) => message.runId === run.runId && message.role === "assistant",
328
+ );
329
+ const assistant = assistantMessage(run, notification);
330
+ const assistantAt =
331
+ run.admittedAt ??
332
+ (assistantIndex >= 0 ? state.messages[assistantIndex]?.at : undefined);
333
+ if (assistantAt) assistant.at = assistantAt;
334
+ if (assistantIndex >= 0) state.messages[assistantIndex] = assistant;
335
+ else state.messages.push(assistant);
336
+
337
+ activeRun = activeRunView(run) ?? activeRun;
338
+ if (run.status === "running" || run.status === "reconciliation-required") {
339
+ busyRunId = run.runId;
340
+ }
341
+ if (notification && isTerminalRun(run)) {
342
+ projected.add(notification.notificationId);
343
+ }
344
+ }
345
+
346
+ if (observedRunId && runs.some((run) => run.runId === observedRunId)) {
347
+ state.error = undefined;
348
+ }
349
+
350
+ const terminalRunIds = new Set(
351
+ runs.filter(isTerminalRun).map((run) => run.runId),
352
+ );
353
+ if (busyRunId) state.activeRunId = busyRunId;
354
+ else if (state.activeRunId && terminalRunIds.has(state.activeRunId)) {
355
+ state.activeRunId = undefined;
356
+ }
357
+ if (activeRun) state.activeRun = activeRun;
358
+ else if (
359
+ state.activeRun &&
360
+ runs.some((run) => run.runId === state.activeRun?.runId)
361
+ ) {
362
+ // The Turn this banner described has moved on to a state that needs none.
363
+ state.activeRun = undefined;
364
+ }
365
+ return projected;
366
+ }
367
+
368
+ export function projectCompletedRuns(
369
+ messages: WebChatMessage[],
370
+ notifications: readonly ClientNotificationIntent[],
371
+ runs: readonly ClientRun[],
372
+ ): Set<string> {
373
+ return projectDurableRuns(
374
+ { messages },
375
+ notifications,
376
+ runs.filter(isTerminalRun),
377
+ );
378
+ }
379
+
380
+ interface PendingConnectionOperation {
381
+ commandId: string;
382
+ createdAt: number;
383
+ expiresAt?: number;
384
+ nativeReturnNonce?: string;
385
+ packageId?: string;
386
+ connectionId?: string;
387
+ }
388
+
389
+ const CONNECTION_OPERATION_STORAGE_KEY =
390
+ "frockbot.pending-connection-operations.v1";
391
+
392
+ function readConnectionOperations(): Record<
393
+ string,
394
+ PendingConnectionOperation
395
+ > {
396
+ try {
397
+ const storage = globalThis.localStorage;
398
+ if (!storage) return {};
399
+ const value: unknown = JSON.parse(
400
+ storage.getItem(CONNECTION_OPERATION_STORAGE_KEY) ?? "{}",
401
+ );
402
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
403
+ return Object.fromEntries(
404
+ Object.entries(value).flatMap(([key, candidate]) => {
405
+ if (
406
+ !candidate ||
407
+ typeof candidate !== "object" ||
408
+ Array.isArray(candidate)
409
+ ) {
410
+ return [];
411
+ }
412
+ const operation = candidate as Record<string, unknown>;
413
+ const allowed = new Set([
414
+ "commandId",
415
+ "createdAt",
416
+ "expiresAt",
417
+ "nativeReturnNonce",
418
+ "packageId",
419
+ "connectionId",
420
+ ]);
421
+ if (
422
+ Object.keys(operation).some((field) => !allowed.has(field)) ||
423
+ typeof operation.commandId !== "string" ||
424
+ typeof operation.createdAt !== "number" ||
425
+ (operation.expiresAt !== undefined &&
426
+ typeof operation.expiresAt !== "number") ||
427
+ (operation.nativeReturnNonce !== undefined &&
428
+ typeof operation.nativeReturnNonce !== "string") ||
429
+ (operation.packageId !== undefined &&
430
+ typeof operation.packageId !== "string") ||
431
+ (operation.connectionId !== undefined &&
432
+ typeof operation.connectionId !== "string")
433
+ ) {
434
+ return [];
435
+ }
436
+ return [
437
+ [
438
+ key,
439
+ {
440
+ commandId: operation.commandId,
441
+ createdAt: operation.createdAt,
442
+ ...(typeof operation.expiresAt === "number"
443
+ ? { expiresAt: operation.expiresAt }
444
+ : {}),
445
+ ...(typeof operation.nativeReturnNonce === "string"
446
+ ? { nativeReturnNonce: operation.nativeReturnNonce }
447
+ : {}),
448
+ ...(typeof operation.packageId === "string"
449
+ ? { packageId: operation.packageId }
450
+ : {}),
451
+ ...(typeof operation.connectionId === "string"
452
+ ? { connectionId: operation.connectionId }
453
+ : {}),
454
+ },
455
+ ],
456
+ ];
457
+ }),
458
+ );
459
+ } catch {
460
+ return {};
461
+ }
462
+ }
463
+
464
+ function writeConnectionOperations(
465
+ operations: Record<string, PendingConnectionOperation>,
466
+ ): void {
467
+ try {
468
+ globalThis.localStorage?.setItem(
469
+ CONNECTION_OPERATION_STORAGE_KEY,
470
+ JSON.stringify(operations),
471
+ );
472
+ } catch {
473
+ return;
474
+ }
475
+ }
476
+
477
+ function synchronizeConnectionOperations(
478
+ operations: Record<string, PendingConnectionOperation>,
479
+ ): void {
480
+ for (const key of Object.keys(operations)) delete operations[key];
481
+ Object.assign(operations, readConnectionOperations());
482
+ }
483
+
484
+ async function reserveConnectionOperation(
485
+ operations: Record<string, PendingConnectionOperation>,
486
+ operationKey: string,
487
+ create: () => PendingConnectionOperation,
488
+ settled?: (operation: PendingConnectionOperation) => Promise<boolean>,
489
+ ): Promise<PendingConnectionOperation> {
490
+ const reserve = async () => {
491
+ synchronizeConnectionOperations(operations);
492
+ let operation: PendingConnectionOperation | undefined =
493
+ operations[operationKey];
494
+ if (operation && settled && (await settled(operation))) {
495
+ delete operations[operationKey];
496
+ operation = undefined;
497
+ }
498
+ operation ??= create();
499
+ operations[operationKey] = operation;
500
+ writeConnectionOperations(operations);
501
+ return operation;
502
+ };
503
+ const locks = globalThis.navigator?.locks;
504
+ return locks
505
+ ? locks.request(
506
+ `${CONNECTION_OPERATION_STORAGE_KEY}:${operationKey}`,
507
+ reserve,
508
+ )
509
+ : reserve();
510
+ }
511
+
512
+ function retireSettledConnectionOperations(
513
+ operations: Record<string, PendingConnectionOperation>,
514
+ settings: UserSettingsViewV1,
515
+ ): void {
516
+ synchronizeConnectionOperations(operations);
517
+ let changed = false;
518
+ for (const [key, operation] of Object.entries(operations)) {
519
+ if (operation.connectionId !== undefined) continue;
520
+ const connection = settings.connections.find(
521
+ (candidate) =>
522
+ candidate.connectionId === operation.commandId ||
523
+ candidate.safeMetadata.creationCommandId === operation.commandId,
524
+ );
525
+ if (
526
+ connection &&
527
+ (connection.state === "ready" ||
528
+ connection.state === "failed" ||
529
+ connection.state === "revoked")
530
+ ) {
531
+ delete operations[key];
532
+ changed = true;
533
+ }
534
+ }
535
+ if (changed) writeConnectionOperations(operations);
536
+ }
537
+
538
+ async function reconcileRetainedConnectionCommands(
539
+ operations: Record<string, PendingConnectionOperation>,
540
+ lookup: AgentTransport["lookupConnectionCommand"] | undefined,
541
+ ): Promise<void> {
542
+ if (!lookup) return;
543
+ synchronizeConnectionOperations(operations);
544
+ let changed = false;
545
+ for (const [key, operation] of Object.entries(operations)) {
546
+ if (!operation.packageId || !operation.connectionId) continue;
547
+ try {
548
+ const receipt = await lookup(operation.packageId, operation.commandId);
549
+ if (!receipt) continue;
550
+ delete operations[key];
551
+ changed = true;
552
+ } catch (error) {
553
+ void error;
554
+ }
555
+ }
556
+ if (changed) writeConnectionOperations(operations);
557
+ }
558
+
559
+ function isDefinitiveConnectionFailure(error: unknown): boolean {
560
+ return (
561
+ typeof error === "object" &&
562
+ error !== null &&
563
+ "definitive" in error &&
564
+ error.definitive === true
565
+ );
566
+ }
567
+
568
+ function isRecord(value: unknown): value is Record<string, unknown> {
569
+ return typeof value === "object" && value !== null && !Array.isArray(value);
570
+ }
571
+
572
+ function hasExactFields(
573
+ value: Record<string, unknown>,
574
+ fields: readonly string[],
575
+ ): boolean {
576
+ return (
577
+ Object.keys(value).length === fields.length &&
578
+ fields.every((field) => Object.hasOwn(value, field))
579
+ );
580
+ }
581
+
582
+ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
583
+ if (
584
+ !isRecord(value) ||
585
+ !hasExactFields(value, [
586
+ "schemaVersion",
587
+ "deployment",
588
+ "applicationHash",
589
+ "packages",
590
+ ]) ||
591
+ value.schemaVersion !== 1 ||
592
+ !isRecord(value.deployment) ||
593
+ !hasExactFields(value.deployment, ["userId", "applicationHash"]) ||
594
+ typeof value.deployment.userId !== "string" ||
595
+ value.deployment.userId.length === 0 ||
596
+ value.deployment.userId.length > 256 ||
597
+ typeof value.deployment.applicationHash !== "string" ||
598
+ value.deployment.applicationHash.length === 0 ||
599
+ value.deployment.applicationHash.length > 256 ||
600
+ typeof value.applicationHash !== "string" ||
601
+ value.applicationHash.length === 0 ||
602
+ value.applicationHash.length > 256 ||
603
+ // `deployment.applicationHash` names the artifact bytes the gateway
604
+ // loaded; `applicationHash` is the compiled plan's digest. They differ
605
+ // by construction, so each is checked on its own and never against
606
+ // the other.
607
+ !Array.isArray(value.packages) ||
608
+ value.packages.length > 256
609
+ ) {
610
+ throw new Error("Application manifest is invalid");
611
+ }
612
+ return value.packages.flatMap((candidate) => {
613
+ if (
614
+ !isRecord(candidate) ||
615
+ // A Package that declares no configuration is serialised without the
616
+ // key (JSON drops an undefined field), so the key is owned but optional.
617
+ !hasExactFields(
618
+ candidate,
619
+ Object.hasOwn(candidate, "configuration")
620
+ ? ["id", "displayName", "version", "contributions", "configuration"]
621
+ : ["id", "displayName", "version", "contributions"],
622
+ ) ||
623
+ typeof candidate.id !== "string" ||
624
+ typeof candidate.displayName !== "string" ||
625
+ typeof candidate.version !== "string" ||
626
+ !Array.isArray(candidate.contributions) ||
627
+ candidate.contributions.length > 5 ||
628
+ new Set(candidate.contributions).size !==
629
+ candidate.contributions.length ||
630
+ !candidate.contributions.every(
631
+ (kind) =>
632
+ kind === "backend" ||
633
+ kind === "runtime" ||
634
+ kind === "client" ||
635
+ kind === "desktop" ||
636
+ kind === "mobile",
637
+ )
638
+ ) {
639
+ throw new Error("Application Package metadata is invalid");
640
+ }
641
+ const decoded = decodeFrockBotManifest({
642
+ // v4, so a Capability carrying an admission ceiling decodes here too.
643
+ // Admission is durable manifest state the Plugins surface does not
644
+ // render; refusing the manifest over it would hide the whole Package.
645
+ schemaVersion: 4,
646
+ id: candidate.id,
647
+ displayName: candidate.displayName,
648
+ version: candidate.version,
649
+ compatibility: { frockbot: "*" },
650
+ dependencies: {},
651
+ contributions: { runtime: { entry: "./manifest-validation.js" } },
652
+ permissions: [],
653
+ configuration: candidate.configuration,
654
+ });
655
+ const connectionTypes = decoded.configuration?.connectionTypes ?? [];
656
+ const decodedCapabilities = (decoded.configuration?.capabilities ?? []).map(
657
+ (capability) => ({
658
+ id: capability.id,
659
+ kind: capability.kind,
660
+ connectionTypes: capability.connectionTypes,
661
+ }),
662
+ );
663
+ // A Package with neither a Connection Type nor a Capability contributes
664
+ // nothing the Plugins surface can install or assign. A Capability that
665
+ // takes no Connection still counts: a tool Package a User installs and
666
+ // assigns without any credential is exactly that shape.
667
+ if (connectionTypes.length === 0 && decodedCapabilities.length === 0) {
668
+ return [];
669
+ }
670
+ const decodedConnections = connectionTypes.map((connection) => {
671
+ const authorizationKind: PluginCatalogItem["connectionTypes"][number]["authorizationKind"] =
672
+ connection.authorization.kind;
673
+ return {
674
+ id: connection.id,
675
+ displayName: connection.displayName,
676
+ allowMultiple: connection.allowMultiple,
677
+ authorizationKind,
678
+ capabilities: connection.capabilities,
679
+ };
680
+ });
681
+ return [
682
+ {
683
+ packageId: candidate.id,
684
+ displayName:
685
+ typeof candidate.displayName === "string"
686
+ ? candidate.displayName
687
+ : candidate.id,
688
+ version: candidate.version,
689
+ capabilities: decodedCapabilities,
690
+ connectionTypes: decodedConnections,
691
+ // User-scoped settings only: a `bot`-scoped one is not the Plugins
692
+ // surface's to edit, and a Connection-scoped one is edited with its
693
+ // Connection.
694
+ settings: (decoded.configuration?.settings ?? []).filter((setting) =>
695
+ setting.scopes.includes("user"),
696
+ ),
697
+ },
698
+ ];
699
+ });
700
+ }
701
+
702
+ export const shellClientPlugin: ClientPlugin = (ctx) => {
703
+ const surfaces = createClientSurfaceRegistry();
704
+ let activeRequest: AbortController | undefined;
705
+ let admissionObserver: AbortController | undefined;
706
+ let runObserver: AbortController | undefined;
707
+ let selectionGeneration = 0;
708
+ let userSettingsGeneration = 0;
709
+ let pluginCatalogGeneration = 0;
710
+ let packageCatalogGeneration = 0;
711
+ const settingsLoadErrors = new Map<
712
+ "bot" | "user" | "catalog" | "package-catalog",
713
+ string
714
+ >();
715
+ const connectionOperations = readConnectionOperations();
716
+ const stopCommands = new Map<string, string>();
717
+ const authorizationOperations = new Map<
718
+ string,
719
+ { nativeReturnNonce?: string }
720
+ >();
721
+
722
+ async function executeRetainedApiKeyCommand(
723
+ identity: readonly string[],
724
+ create: (commandId: string) => ConnectionCommandV1,
725
+ pending: Pick<
726
+ PendingConnectionOperation,
727
+ "packageId" | "connectionId"
728
+ > = {},
729
+ ): Promise<ConnectionCommandReceiptV1> {
730
+ if (!ctx.transport.executeConnection) {
731
+ throw new Error("Connections are unavailable");
732
+ }
733
+ const userId = await ctx.transport.readAuthenticatedUserId?.();
734
+ if (!userId) {
735
+ throw new Error("Authenticated User identity is unavailable");
736
+ }
737
+ const operationKey = JSON.stringify(["api-key", userId, ...identity]);
738
+ const operation = await reserveConnectionOperation(
739
+ connectionOperations,
740
+ operationKey,
741
+ () => ({
742
+ commandId: crypto.randomUUID(),
743
+ createdAt: Date.now(),
744
+ ...pending,
745
+ }),
746
+ pending.packageId &&
747
+ pending.connectionId &&
748
+ ctx.transport.lookupConnectionCommand
749
+ ? async (existing) =>
750
+ Boolean(
751
+ await ctx.transport.lookupConnectionCommand!(
752
+ pending.packageId!,
753
+ existing.commandId,
754
+ ),
755
+ )
756
+ : undefined,
757
+ );
758
+ try {
759
+ const result = await ctx.transport.executeConnection(
760
+ create(operation.commandId),
761
+ );
762
+ delete connectionOperations[operationKey];
763
+ writeConnectionOperations(connectionOperations);
764
+ return result;
765
+ } catch (error) {
766
+ if (isDefinitiveConnectionFailure(error)) {
767
+ delete connectionOperations[operationKey];
768
+ writeConnectionOperations(connectionOperations);
769
+ }
770
+ throw error;
771
+ }
772
+ }
773
+
774
+ async function waitForRunLookup(
775
+ delayMs: number,
776
+ signal: AbortSignal,
777
+ ): Promise<void> {
778
+ if (signal.aborted) return;
779
+ await new Promise<void>((resolve) => {
780
+ const timeout = setTimeout(finish, delayMs);
781
+ function finish() {
782
+ clearTimeout(timeout);
783
+ signal.removeEventListener("abort", finish);
784
+ resolve();
785
+ }
786
+ signal.addEventListener("abort", finish, { once: true });
787
+ });
788
+ }
789
+
790
+ async function observeWhileAttached<T>(
791
+ operation: Promise<T>,
792
+ signal: AbortSignal,
793
+ ): Promise<T> {
794
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
795
+ let rejectOnAbort: (() => void) | undefined;
796
+ const aborted = new Promise<never>((_, reject) => {
797
+ rejectOnAbort = () => reject(new DOMException("Aborted", "AbortError"));
798
+ signal.addEventListener("abort", rejectOnAbort, { once: true });
799
+ });
800
+ try {
801
+ return await Promise.race([operation, aborted]);
802
+ } finally {
803
+ if (rejectOnAbort) signal.removeEventListener("abort", rejectOnAbort);
804
+ }
805
+ }
806
+
807
+ async function reconcileUncertainAdmission(
808
+ botId: string,
809
+ runId: string,
810
+ signal: AbortSignal,
811
+ ): Promise<"admitted" | "not-admitted" | "detached"> {
812
+ web.value.activeRun = {
813
+ runId,
814
+ status: "running",
815
+ message: "Confirming whether this Turn was admitted.",
816
+ canResume: false,
817
+ };
818
+ if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
819
+ return "detached";
820
+ }
821
+ let delayMs = 250;
822
+ let reconciliationError: string | undefined;
823
+ const clearReconciliationError = () => {
824
+ if (web.value.settingsError === reconciliationError) {
825
+ web.value.settingsError = undefined;
826
+ }
827
+ reconciliationError = undefined;
828
+ };
829
+ while (!signal.aborted) {
830
+ try {
831
+ const observed = await observeWhileAttached(
832
+ ctx.transport.lookupRun(botId, runId),
833
+ signal,
834
+ );
835
+ clearReconciliationError();
836
+ const run =
837
+ observed ??
838
+ (await observeWhileAttached(
839
+ ctx.transport.fenceRunAdmission(botId, runId),
840
+ signal,
841
+ ));
842
+ clearReconciliationError();
843
+ if (signal.aborted) return "detached";
844
+ if (!run) {
845
+ web.value.activeRun = undefined;
846
+ return "not-admitted";
847
+ }
848
+ projectDurableRuns(web.value, [], [run]);
849
+ if (isTerminalRun(run)) return "admitted";
850
+ } catch (error) {
851
+ if (signal.aborted) return "detached";
852
+ reconciliationError = `${
853
+ error instanceof Error
854
+ ? error.message
855
+ : "Turn admission lookup failed"
856
+ } Retrying…`;
857
+ web.value.settingsError = reconciliationError;
858
+ }
859
+ await waitForRunLookup(delayMs, signal);
860
+ delayMs = Math.min(delayMs * 2, 5_000);
861
+ }
862
+ return "detached";
863
+ }
864
+
865
+ async function observeRunUntilTerminal(
866
+ botId: string,
867
+ runId: string,
868
+ generation: number,
869
+ signal: AbortSignal,
870
+ ): Promise<void> {
871
+ if (!ctx.transport.lookupRun) return;
872
+ let delayMs = 250;
873
+ let observationError: string | undefined;
874
+ while (!signal.aborted) {
875
+ try {
876
+ const run = await observeWhileAttached(
877
+ ctx.transport.lookupRun(botId, runId),
878
+ signal,
879
+ );
880
+ if (
881
+ signal.aborted ||
882
+ generation !== selectionGeneration ||
883
+ web.value.activeBotId !== botId
884
+ ) {
885
+ return;
886
+ }
887
+ if (!run) throw new Error("Stopped Turn is unavailable");
888
+ if (web.value.settingsError === observationError) {
889
+ web.value.settingsError = undefined;
890
+ }
891
+ observationError = undefined;
892
+ projectDurableRuns(web.value, [], [run]);
893
+ if (isTerminalRun(run)) return;
894
+ } catch (error) {
895
+ if (signal.aborted) return;
896
+ observationError = `${
897
+ error instanceof Error ? error.message : "Turn lookup failed"
898
+ } Retrying…`;
899
+ web.value.settingsError = observationError;
900
+ }
901
+ await waitForRunLookup(delayMs, signal);
902
+ delayMs = Math.min(delayMs * 2, 5_000);
903
+ }
904
+ }
905
+
906
+ async function deliverNotifications(
907
+ botId: string,
908
+ generation = selectionGeneration,
909
+ ): Promise<void> {
910
+ const runs = await (ctx.transport.listRuns?.(botId) ?? Promise.resolve([]));
911
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
912
+ return;
913
+ projectDurableRuns(web.value, [], runs);
914
+ try {
915
+ const announcements = await (ctx.transport.listAnnouncements?.(botId) ??
916
+ Promise.resolve([]));
917
+ if (generation === selectionGeneration && web.value.activeBotId === botId)
918
+ projectAnnouncements(web.value.messages, announcements);
919
+ } catch {
920
+ // Announcements are conversational history, never admission: a Session
921
+ // that cannot read them still shows every Turn.
922
+ }
923
+ let notifications: ClientNotificationIntent[];
924
+ try {
925
+ notifications = await (ctx.transport.listNotifications?.(botId) ??
926
+ Promise.resolve([]));
927
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
928
+ return;
929
+ } catch (error) {
930
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
931
+ return;
932
+ web.value.settingsError =
933
+ error instanceof Error ? error.message : "Could not load notifications";
934
+ return;
935
+ }
936
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
937
+ return;
938
+ const projected = projectDurableRuns(web.value, notifications, runs);
939
+ // A decision may have been recorded on another device since the last poll,
940
+ // and an expiry is recorded by an alarm nobody clicked.
941
+ await web.value.loadApprovals();
942
+ // A background subagent settles after its Turn is over, so the chips in
943
+ // the transcript learn what became of it here and not from the run.
944
+ await web.value.loadTasks();
945
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
946
+ return;
947
+ if (!ctx.transport.acknowledgeNotification) return;
948
+ for (const notification of notifications) {
949
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
950
+ return;
951
+ if (!projected.has(notification.notificationId)) {
952
+ web.value.settingsError = "A completed Bot result is waiting to load";
953
+ continue;
954
+ }
955
+ if (document.hidden) {
956
+ // One seam: the desktop or mobile notifications Package when the shell
957
+ // exposes it, the web API when it does not.
958
+ const delivery = await showClientNotificationV1({
959
+ title: notification.title,
960
+ body: notification.body,
961
+ // An approval is recorded at `critical` whatever the Bot's
962
+ // notification policy says; the shell decides what critical means.
963
+ ...(notification.urgency === undefined
964
+ ? {}
965
+ : { urgency: notification.urgency }),
966
+ });
967
+ if (delivery === "unavailable") {
968
+ web.value.settingsError =
969
+ "A completed Bot notification is waiting for permission";
970
+ continue;
971
+ }
972
+ }
973
+ await ctx.transport.acknowledgeNotification(
974
+ botId,
975
+ notification.notificationId,
976
+ );
977
+ }
978
+ }
979
+
980
+ async function executeAssignmentOperation(
981
+ command: Extract<
982
+ ConfigurationCommandV1,
983
+ {
984
+ type:
985
+ | "bot/assign-capability"
986
+ | "bot/replace-capability"
987
+ | "bot/unassign-capability";
988
+ }
989
+ >,
990
+ ): Promise<void> {
991
+ const execute = ctx.transport.executeConfiguration;
992
+ if (!execute) throw new Error("Settings are unavailable");
993
+ const receipt = (await execute(command)) as OperationReceiptV1;
994
+ await web.value.loadBotSettings();
995
+ if (receipt.status === "rejected") {
996
+ const failure = receipt.failure ?? "Assignment operation was rejected";
997
+ web.value.settingsError = failure;
998
+ throw new Error(failure);
999
+ }
1000
+ if (receipt.status === "pending") {
1001
+ web.value.settingsError = "Assignment operation is retrying.";
1002
+ }
1003
+ }
1004
+
1005
+ function updateSettingsLoadError(
1006
+ source: "bot" | "user" | "catalog" | "package-catalog",
1007
+ message?: string,
1008
+ ): void {
1009
+ settingsLoadErrors.delete(source);
1010
+ if (message) settingsLoadErrors.set(source, message);
1011
+ web.value.settingsError = [...settingsLoadErrors.values()].at(-1);
1012
+ }
1013
+
1014
+ /**
1015
+ * The Bot runs on its own model when it has one and on the User's default
1016
+ * otherwise, so readiness and the composer label follow the effective model.
1017
+ * A Bot following the default is ready as soon as the User's Connection is:
1018
+ * the Bot's own Assignment for that Connection is claimed durably when the
1019
+ * Turn is admitted.
1020
+ */
1021
+ function updateModelLabel(): void {
1022
+ const bot = web.value.botSettings;
1023
+ const user = web.value.userSettings;
1024
+ const model = bot?.model ?? user?.newBotModelTemplate;
1025
+ web.value.modelSource = bot?.model ? "bot" : model ? "default" : "none";
1026
+ const connection = (user?.connections ?? []).find(
1027
+ (candidate) => candidate.connectionId === model?.connectionId,
1028
+ );
1029
+ const packageInstalled = (user?.packages ?? []).some(
1030
+ (pkg) =>
1031
+ pkg.packageId === connection?.packageId && pkg.state === "installed",
1032
+ );
1033
+ const catalogPackage = web.value.pluginCatalog.find(
1034
+ (pkg) => pkg.packageId === connection?.packageId,
1035
+ );
1036
+ const connectionType = catalogPackage?.connectionTypes.find(
1037
+ (candidate) => candidate.id === connection?.connectionTypeId,
1038
+ );
1039
+ const modelCapabilities = new Set(
1040
+ catalogPackage?.capabilities.flatMap((capability) =>
1041
+ capability.kind === "model" &&
1042
+ connectionType?.capabilities.includes(capability.id)
1043
+ ? [capability.id]
1044
+ : [],
1045
+ ) ?? [],
1046
+ );
1047
+ const authorized =
1048
+ web.value.modelSource === "bot"
1049
+ ? Boolean(
1050
+ bot?.assignments.some(
1051
+ (assignment) =>
1052
+ assignment.connectionId === model?.connectionId &&
1053
+ assignment.packageId === connection?.packageId &&
1054
+ assignment.state === "enabled" &&
1055
+ modelCapabilities.has(assignment.capabilityId),
1056
+ ),
1057
+ )
1058
+ : modelCapabilities.size > 0;
1059
+ web.value.modelReady = Boolean(
1060
+ model && connection?.state === "ready" && packageInstalled && authorized,
1061
+ );
1062
+ const catalogModel = connection?.modelCatalog?.models.find(
1063
+ (candidate) => candidate.providerModelId === model?.providerModelId,
1064
+ );
1065
+ web.value.modelLabel = modelRuntimeLabel({
1066
+ modelDisplayName: catalogModel?.displayName,
1067
+ providerModelId: model?.providerModelId,
1068
+ packageDisplayName: catalogPackage?.displayName,
1069
+ connectionDisplayName: connection?.displayName,
1070
+ hasModel: Boolean(model),
1071
+ });
1072
+ }
1073
+
1074
+ /**
1075
+ * One MCP lifecycle command, followed by a fresh status read. A refusal —
1076
+ * a stdio server, a quota breach — comes back as a receipt, not an
1077
+ * exception, and the surface shows it beside the servers rather than as an
1078
+ * error that loses the reason.
1079
+ */
1080
+ async function executeMcpCommand(command: unknown): Promise<void> {
1081
+ if (!ctx.transport.hostedRequest) {
1082
+ throw new Error("MCP lifecycle is unavailable");
1083
+ }
1084
+ try {
1085
+ const receipt = decodeMcpLifecycleReceiptV1(
1086
+ await ctx.transport.hostedRequest(
1087
+ MCP_SERVERS_ROUTE,
1088
+ "POST",
1089
+ JSON.stringify(command),
1090
+ ),
1091
+ );
1092
+ if (receipt.status !== "applied") {
1093
+ web.value.settingsError =
1094
+ receipt.failure ?? "The MCP command was refused";
1095
+ } else {
1096
+ web.value.settingsError = undefined;
1097
+ }
1098
+ } catch (error) {
1099
+ web.value.settingsError =
1100
+ error instanceof Error ? error.message : "The MCP command failed";
1101
+ }
1102
+ await web.value.loadMcpServers();
1103
+ }
1104
+
1105
+ const web = ref<FrockBotWebData>({
1106
+ connection: "ready",
1107
+ modelLabel: "No default model",
1108
+ modelReady: false,
1109
+ modelSource: "none",
1110
+ settingsAvailable: true,
1111
+ connectionsAvailable: ctx.transport.connectionsAvailable !== false,
1112
+ activeBotId: undefined,
1113
+ composerContext: undefined,
1114
+ messages: [],
1115
+ pluginCatalog: [],
1116
+ packageCatalog: [],
1117
+ skillCatalog: [],
1118
+ approvals: [],
1119
+ tasks: [],
1120
+ async selectBot(botId: string): Promise<void> {
1121
+ // Re-selecting the open Bot is not a switch: aborting the live Turn and
1122
+ // clearing the transcript would discard state the User is watching.
1123
+ if (web.value.activeBotId === botId) return;
1124
+ activeRequest?.abort();
1125
+ admissionObserver?.abort();
1126
+ runObserver?.abort();
1127
+ selectionGeneration += 1;
1128
+ web.value.activeBotId = botId;
1129
+ web.value.composerContext = botId;
1130
+ web.value.botSettings = undefined;
1131
+ web.value.modelReady = false;
1132
+ web.value.messages = [];
1133
+ web.value.activeRun = undefined;
1134
+ web.value.activeRunId = undefined;
1135
+ web.value.skillCatalog = [];
1136
+ web.value.approvals = [];
1137
+ web.value.tasks = [];
1138
+ const url = URL.parse(window.location.href);
1139
+ if (url) {
1140
+ url.searchParams.set("bot", botId);
1141
+ window.history.replaceState(null, "", url);
1142
+ }
1143
+ await web.value.loadBotSettings();
1144
+ },
1145
+ async loadSkillCatalog(): Promise<void> {
1146
+ // A missing transport method or an unreadable catalog is an empty
1147
+ // popover, never a visible error: a Skill list the User did not ask for
1148
+ // must not put a banner over their conversation.
1149
+ const read = ctx.transport.readSkillCatalog;
1150
+ const botId = web.value.activeBotId;
1151
+ if (!read || !botId) return;
1152
+ const generation = selectionGeneration;
1153
+ try {
1154
+ const catalog = decodeClientSkillCatalogV1(await read(botId));
1155
+ if (
1156
+ generation !== selectionGeneration ||
1157
+ web.value.activeBotId !== botId
1158
+ )
1159
+ return;
1160
+ web.value.skillCatalog = catalog.skills;
1161
+ } catch {
1162
+ if (
1163
+ generation === selectionGeneration &&
1164
+ web.value.activeBotId === botId
1165
+ )
1166
+ web.value.skillCatalog = [];
1167
+ }
1168
+ },
1169
+ async loadApprovals(): Promise<void> {
1170
+ // A deployment with no approvals route, or one that cannot be read, is
1171
+ // an empty list rather than a banner: the cards in the transcript then
1172
+ // simply say they cannot be answered here.
1173
+ const read = ctx.transport.hostedRequest;
1174
+ const botId = web.value.activeBotId;
1175
+ if (!read || !botId) return;
1176
+ const generation = selectionGeneration;
1177
+ try {
1178
+ const view = decodeApprovalListViewV1(
1179
+ await read(`/api/bots/${encodeURIComponent(botId)}/approvals`),
1180
+ );
1181
+ if (
1182
+ generation !== selectionGeneration ||
1183
+ web.value.activeBotId !== botId
1184
+ )
1185
+ return;
1186
+ web.value.approvals = view.approvals;
1187
+ } catch {
1188
+ if (
1189
+ generation === selectionGeneration &&
1190
+ web.value.activeBotId === botId
1191
+ )
1192
+ web.value.approvals = [];
1193
+ }
1194
+ },
1195
+ async loadTasks(): Promise<void> {
1196
+ // A deployment with no tasks route, or one that cannot be read, is an
1197
+ // empty list rather than a banner: the chips in the transcript then say
1198
+ // what the dispatch said and nothing more.
1199
+ const read = ctx.transport.hostedRequest;
1200
+ const botId = web.value.activeBotId;
1201
+ if (!read || !botId) return;
1202
+ const generation = selectionGeneration;
1203
+ try {
1204
+ const view = decodeTaskListViewV1(
1205
+ await read(`/api/bots/${encodeURIComponent(botId)}/tasks`),
1206
+ );
1207
+ if (
1208
+ generation !== selectionGeneration ||
1209
+ web.value.activeBotId !== botId
1210
+ )
1211
+ return;
1212
+ web.value.tasks = view.tasks;
1213
+ } catch {
1214
+ if (
1215
+ generation === selectionGeneration &&
1216
+ web.value.activeBotId === botId
1217
+ )
1218
+ web.value.tasks = [];
1219
+ }
1220
+ },
1221
+ /**
1222
+ * Explicit, authenticated cancellation of one subagent from the client.
1223
+ *
1224
+ * The receipt is the task as the Bot Durable Object recorded it, not what
1225
+ * the click asked for: a task that had already settled comes back settled,
1226
+ * and the list shows that rather than a cancellation that did not happen.
1227
+ */
1228
+ async stopTask(taskId: string): Promise<void> {
1229
+ const post = ctx.transport.hostedRequest;
1230
+ const botId = web.value.activeBotId;
1231
+ if (!post || !botId) return;
1232
+ try {
1233
+ const view = decodeTaskViewV1(
1234
+ await post(
1235
+ `/api/bots/${encodeURIComponent(botId)}/tasks/${encodeURIComponent(taskId)}/stop`,
1236
+ "POST",
1237
+ JSON.stringify({ schemaVersion: 1 }),
1238
+ ),
1239
+ );
1240
+ if (web.value.activeBotId !== botId) return;
1241
+ web.value.tasks = web.value.tasks.map((task) =>
1242
+ task.taskId === view.taskId ? view : task,
1243
+ );
1244
+ } catch (error) {
1245
+ web.value.settingsError =
1246
+ error instanceof Error
1247
+ ? error.message
1248
+ : "Could not stop the subagent";
1249
+ }
1250
+ },
1251
+ async decideApproval(
1252
+ approvalId: string,
1253
+ decision: "approved" | "denied",
1254
+ ): Promise<void> {
1255
+ const post = ctx.transport.hostedRequest;
1256
+ const botId = web.value.activeBotId;
1257
+ if (!post || !botId) return;
1258
+ try {
1259
+ // The receipt carries what was actually recorded, which on a replay is
1260
+ // somebody else's earlier answer. Rendering the receipt rather than the
1261
+ // click is what keeps this client from becoming a second authority.
1262
+ const receipt = decodeApprovalDecisionReceiptV1(
1263
+ await post(
1264
+ `/api/bots/${encodeURIComponent(botId)}/approvals/${encodeURIComponent(approvalId)}`,
1265
+ "POST",
1266
+ JSON.stringify({ schemaVersion: 1, decision }),
1267
+ ),
1268
+ );
1269
+ if (web.value.activeBotId !== botId) return;
1270
+ const others = web.value.approvals.filter(
1271
+ (approval) => approval.approvalId !== approvalId,
1272
+ );
1273
+ web.value.approvals = [receipt.approval, ...others];
1274
+ } catch (error) {
1275
+ web.value.settingsError =
1276
+ error instanceof Error
1277
+ ? error.message
1278
+ : "Could not record the decision";
1279
+ }
1280
+ },
1281
+ async loadBotSettings(): Promise<void> {
1282
+ if (!ctx.transport.readConfiguration) {
1283
+ updateSettingsLoadError("bot", "Settings are unavailable");
1284
+ return;
1285
+ }
1286
+ const botId = web.value.activeBotId;
1287
+ if (!botId) return;
1288
+ const generation = selectionGeneration;
1289
+ try {
1290
+ const settings = (await ctx.transport.readConfiguration({
1291
+ schemaVersion: 1,
1292
+ type: "bot/get",
1293
+ botId,
1294
+ })) as BotSettingsViewV1;
1295
+ if (
1296
+ generation !== selectionGeneration ||
1297
+ web.value.activeBotId !== botId
1298
+ )
1299
+ return;
1300
+ web.value.botSettings = settings;
1301
+ // The effective model may be the User's default, so Bot readiness
1302
+ // needs the User settings too. Loading them never fails this read:
1303
+ // `loadUserSettings` reports its own failure.
1304
+ if (!web.value.userSettings) await web.value.loadUserSettings();
1305
+ if (
1306
+ generation !== selectionGeneration ||
1307
+ web.value.activeBotId !== botId
1308
+ )
1309
+ return;
1310
+ updateModelLabel();
1311
+ updateSettingsLoadError("bot");
1312
+ await deliverNotifications(botId, generation);
1313
+ } catch (error) {
1314
+ if (
1315
+ generation !== selectionGeneration ||
1316
+ web.value.activeBotId !== botId
1317
+ )
1318
+ return;
1319
+ updateSettingsLoadError(
1320
+ "bot",
1321
+ error instanceof Error ? error.message : "Could not load settings",
1322
+ );
1323
+ }
1324
+ },
1325
+ async saveBotProfile(profile: BotProfile): Promise<void> {
1326
+ const current = web.value.botSettings;
1327
+ const botId = web.value.activeBotId;
1328
+ if (!current || !botId || !ctx.transport.executeConfiguration) {
1329
+ throw new Error("Settings are unavailable");
1330
+ }
1331
+ await ctx.transport.executeConfiguration({
1332
+ schemaVersion: 1,
1333
+ type: "bot/update-profile",
1334
+ commandId: crypto.randomUUID(),
1335
+ botId,
1336
+ expectedRevision: current.revision,
1337
+ profile,
1338
+ });
1339
+ await web.value.loadBotSettings();
1340
+ },
1341
+ async setBotProfile(
1342
+ profile: BotProfilePatchV1,
1343
+ namedBy?: BotNameProvenanceV1,
1344
+ ): Promise<void> {
1345
+ const current = web.value.botSettings;
1346
+ const botId = web.value.activeBotId;
1347
+ if (!current || !botId || !ctx.transport.executeConfiguration) {
1348
+ throw new Error("Settings are unavailable");
1349
+ }
1350
+ await ctx.transport.executeConfiguration({
1351
+ schemaVersion: 1,
1352
+ type: "bot/set-profile",
1353
+ commandId: crypto.randomUUID(),
1354
+ botId,
1355
+ expectedRevision: current.revision,
1356
+ ...(namedBy ? { namedBy } : {}),
1357
+ profile,
1358
+ });
1359
+ await web.value.loadBotSettings();
1360
+ // A rename produces a durable announcement; reload the Session so the
1361
+ // system line appears without waiting for the next Turn.
1362
+ await deliverNotifications(botId);
1363
+ },
1364
+ async saveBotNotifications(
1365
+ notifications: BotNotificationPolicy,
1366
+ ): Promise<void> {
1367
+ if (
1368
+ notifications.enabled &&
1369
+ "Notification" in window &&
1370
+ Notification.permission === "default"
1371
+ ) {
1372
+ const permission = await Notification.requestPermission();
1373
+ if (permission !== "granted") {
1374
+ throw new Error("Notification permission was not granted");
1375
+ }
1376
+ }
1377
+ const current = web.value.botSettings;
1378
+ const botId = web.value.activeBotId;
1379
+ if (!current || !botId || !ctx.transport.executeConfiguration) {
1380
+ throw new Error("Settings are unavailable");
1381
+ }
1382
+ await ctx.transport.executeConfiguration({
1383
+ schemaVersion: 1,
1384
+ type: "bot/update-notifications",
1385
+ commandId: crypto.randomUUID(),
1386
+ botId,
1387
+ expectedRevision: current.revision,
1388
+ notifications,
1389
+ });
1390
+ await web.value.loadBotSettings();
1391
+ },
1392
+ async assignCapability(assignment): Promise<void> {
1393
+ const current = web.value.botSettings;
1394
+ const botId = web.value.activeBotId;
1395
+ if (!current || !botId || !ctx.transport.executeConfiguration) {
1396
+ throw new Error("Settings are unavailable");
1397
+ }
1398
+ await executeAssignmentOperation({
1399
+ schemaVersion: 1,
1400
+ type: "bot/assign-capability",
1401
+ commandId: crypto.randomUUID(),
1402
+ botId,
1403
+ expectedRevision: current.revision,
1404
+ assignment,
1405
+ });
1406
+ },
1407
+ async replaceCapability(assignment): Promise<void> {
1408
+ const current = web.value.botSettings;
1409
+ const botId = web.value.activeBotId;
1410
+ if (!current || !botId || !ctx.transport.executeConfiguration) {
1411
+ throw new Error("Settings are unavailable");
1412
+ }
1413
+ await executeAssignmentOperation({
1414
+ schemaVersion: 1,
1415
+ type: "bot/replace-capability",
1416
+ commandId: crypto.randomUUID(),
1417
+ botId,
1418
+ expectedRevision: current.revision,
1419
+ assignment,
1420
+ });
1421
+ },
1422
+ async unassignCapability(assignmentId): Promise<void> {
1423
+ const current = web.value.botSettings;
1424
+ const botId = web.value.activeBotId;
1425
+ if (!current || !botId || !ctx.transport.executeConfiguration) {
1426
+ throw new Error("Settings are unavailable");
1427
+ }
1428
+ await executeAssignmentOperation({
1429
+ schemaVersion: 1,
1430
+ type: "bot/unassign-capability",
1431
+ commandId: crypto.randomUUID(),
1432
+ botId,
1433
+ expectedRevision: current.revision,
1434
+ assignmentId,
1435
+ });
1436
+ },
1437
+ async saveBotModel(model: ModelAssignment): Promise<void> {
1438
+ const current = web.value.botSettings;
1439
+ const user = web.value.userSettings;
1440
+ const botId = web.value.activeBotId;
1441
+ if (!current || !user || !botId || !ctx.transport.executeConfiguration) {
1442
+ throw new Error("Settings are unavailable");
1443
+ }
1444
+ const modelChanged =
1445
+ current.model?.connectionId !== model.connectionId ||
1446
+ current.model?.providerModelId !== model.providerModelId;
1447
+ const connection = user.connections.find(
1448
+ (candidate) => candidate.connectionId === model.connectionId,
1449
+ );
1450
+ if (!modelChanged && connection?.state !== "ready") return;
1451
+ const pkg = web.value.pluginCatalog.find(
1452
+ (candidate) => candidate.packageId === connection?.packageId,
1453
+ );
1454
+ const connectionType = pkg?.connectionTypes.find(
1455
+ (candidate) => candidate.id === connection?.connectionTypeId,
1456
+ );
1457
+ const capability = pkg?.capabilities.find(
1458
+ (candidate) =>
1459
+ candidate.kind === "model" &&
1460
+ connectionType?.capabilities.includes(candidate.id),
1461
+ );
1462
+ if (!connection || connection.state !== "ready" || !pkg || !capability) {
1463
+ throw new Error("The selected Connection has no model capability");
1464
+ }
1465
+ const assigned = current.assignments.some(
1466
+ (assignment) =>
1467
+ assignment.state === "enabled" &&
1468
+ assignment.packageId === pkg.packageId &&
1469
+ assignment.capabilityId === capability.id &&
1470
+ assignment.connectionId === connection.connectionId,
1471
+ );
1472
+ if (assigned && !modelChanged) return;
1473
+ if (!assigned) {
1474
+ // The binding commits inside the Assignment saga's commit phase, so
1475
+ // the Connection claim and the Bot's model are one durable unit. An
1476
+ // existing model Assignment on another Connection is replaced
1477
+ // atomically rather than unassigned and assigned again.
1478
+ const superseded = current.assignments.find(
1479
+ (assignment) =>
1480
+ assignment.packageId === pkg.packageId &&
1481
+ assignment.capabilityId === capability.id,
1482
+ );
1483
+ await executeAssignmentOperation({
1484
+ schemaVersion: 1,
1485
+ type: superseded ? "bot/replace-capability" : "bot/assign-capability",
1486
+ commandId: crypto.randomUUID(),
1487
+ botId,
1488
+ expectedRevision: current.revision,
1489
+ assignment: {
1490
+ assignmentId: superseded?.assignmentId ?? crypto.randomUUID(),
1491
+ packageId: pkg.packageId,
1492
+ capabilityId: capability.id,
1493
+ connectionId: connection.connectionId,
1494
+ },
1495
+ model,
1496
+ });
1497
+ return;
1498
+ }
1499
+ const receipt = await ctx.transport.executeConfiguration({
1500
+ schemaVersion: 1,
1501
+ type: "bot/select-model",
1502
+ commandId: crypto.randomUUID(),
1503
+ botId,
1504
+ expectedRevision: current.revision,
1505
+ model,
1506
+ });
1507
+ await web.value.loadBotSettings();
1508
+ if (receipt.status === "rejected") {
1509
+ throw new Error(receipt.failure);
1510
+ }
1511
+ },
1512
+ async clearBotModel(): Promise<void> {
1513
+ const current = web.value.botSettings;
1514
+ const botId = web.value.activeBotId;
1515
+ if (!current?.model || !botId || !ctx.transport.executeConfiguration) {
1516
+ throw new Error("Settings are unavailable");
1517
+ }
1518
+ const assignment = current.assignments.find((candidate) => {
1519
+ const capability = web.value.pluginCatalog
1520
+ .find((pkg) => pkg.packageId === candidate.packageId)
1521
+ ?.capabilities.find(
1522
+ (declared) => declared.id === candidate.capabilityId,
1523
+ );
1524
+ return (
1525
+ (candidate.state === "enabled" ||
1526
+ candidate.state === "unavailable") &&
1527
+ candidate.connectionId === current.model?.connectionId &&
1528
+ capability?.kind === "model"
1529
+ );
1530
+ });
1531
+ if (!assignment) throw new Error("Bot model assignment is unavailable");
1532
+ const receipt = await ctx.transport.executeConfiguration({
1533
+ schemaVersion: 1,
1534
+ type: "bot/unbind-model",
1535
+ commandId: crypto.randomUUID(),
1536
+ botId,
1537
+ expectedRevision: current.revision,
1538
+ assignmentId: assignment.assignmentId,
1539
+ });
1540
+ await web.value.loadBotSettings();
1541
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
1542
+ },
1543
+ async loadUserSettings(): Promise<void> {
1544
+ if (!ctx.transport.readConfiguration) {
1545
+ updateSettingsLoadError("user", "Settings are unavailable");
1546
+ return;
1547
+ }
1548
+ const generation = ++userSettingsGeneration;
1549
+ try {
1550
+ const settings = (await ctx.transport.readConfiguration({
1551
+ schemaVersion: 1,
1552
+ type: "user/get",
1553
+ })) as UserSettingsViewV1;
1554
+ retireSettledConnectionOperations(connectionOperations, settings);
1555
+ await reconcileRetainedConnectionCommands(
1556
+ connectionOperations,
1557
+ ctx.transport.lookupConnectionCommand,
1558
+ );
1559
+ if (generation !== userSettingsGeneration) return;
1560
+ web.value.userSettings = settings;
1561
+ updateModelLabel();
1562
+ updateSettingsLoadError("user");
1563
+ } catch (error) {
1564
+ if (generation !== userSettingsGeneration) return;
1565
+ updateSettingsLoadError(
1566
+ "user",
1567
+ error instanceof Error ? error.message : "Could not load settings",
1568
+ );
1569
+ }
1570
+ },
1571
+ async saveUserProfile(profile: {
1572
+ name: string;
1573
+ email?: string;
1574
+ }): Promise<void> {
1575
+ const settings = web.value.userSettings;
1576
+ if (!settings || !ctx.transport.executeConfiguration) {
1577
+ throw new Error("Settings are unavailable");
1578
+ }
1579
+ await ctx.transport.executeConfiguration({
1580
+ schemaVersion: 1,
1581
+ type: "user/update-profile",
1582
+ commandId: crypto.randomUUID(),
1583
+ expectedRevision: settings.revision,
1584
+ profile,
1585
+ });
1586
+ await web.value.loadUserSettings();
1587
+ },
1588
+ async saveDefaultModel(model: ModelAssignment | undefined): Promise<void> {
1589
+ const settings = web.value.userSettings;
1590
+ if (!settings || !ctx.transport.executeConfiguration) {
1591
+ throw new Error("Settings are unavailable");
1592
+ }
1593
+ const current = settings.newBotModelTemplate;
1594
+ if (
1595
+ current?.connectionId === model?.connectionId &&
1596
+ current?.providerModelId === model?.providerModelId &&
1597
+ settings.newBotModelTemplateSource === "user"
1598
+ ) {
1599
+ return;
1600
+ }
1601
+ const receipt = await ctx.transport.executeConfiguration({
1602
+ schemaVersion: 1,
1603
+ type: "user/set-new-bot-model",
1604
+ commandId: crypto.randomUUID(),
1605
+ expectedRevision: settings.revision,
1606
+ ...(model ? { model } : {}),
1607
+ source: "user",
1608
+ });
1609
+ await web.value.loadUserSettings();
1610
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
1611
+ },
1612
+ async loadPluginCatalog(): Promise<void> {
1613
+ if (
1614
+ !ctx.transport.readApplicationManifest ||
1615
+ !ctx.transport.readConfiguration
1616
+ ) {
1617
+ updateSettingsLoadError("catalog", "Plugins are unavailable");
1618
+ return;
1619
+ }
1620
+ const userGeneration = ++userSettingsGeneration;
1621
+ const catalogGeneration = ++pluginCatalogGeneration;
1622
+ try {
1623
+ const [manifest, settings] = await Promise.all([
1624
+ ctx.transport.readApplicationManifest(),
1625
+ ctx.transport.readConfiguration({
1626
+ schemaVersion: 1,
1627
+ type: "user/get",
1628
+ }),
1629
+ ]);
1630
+ const pluginCatalog = decodePluginCatalog(manifest);
1631
+ const userSettings = settings as UserSettingsViewV1;
1632
+ retireSettledConnectionOperations(connectionOperations, userSettings);
1633
+ await reconcileRetainedConnectionCommands(
1634
+ connectionOperations,
1635
+ ctx.transport.lookupConnectionCommand,
1636
+ );
1637
+ let committed = false;
1638
+ if (catalogGeneration === pluginCatalogGeneration) {
1639
+ web.value.pluginCatalog = pluginCatalog;
1640
+ updateSettingsLoadError("catalog");
1641
+ committed = true;
1642
+ }
1643
+ if (userGeneration === userSettingsGeneration) {
1644
+ web.value.userSettings = userSettings;
1645
+ updateSettingsLoadError("user");
1646
+ committed = true;
1647
+ }
1648
+ if (committed) updateModelLabel();
1649
+ } catch (error) {
1650
+ if (catalogGeneration !== pluginCatalogGeneration) return;
1651
+ updateSettingsLoadError(
1652
+ "catalog",
1653
+ error instanceof Error ? error.message : "Could not load Plugins",
1654
+ );
1655
+ }
1656
+ },
1657
+ /**
1658
+ * The MCP status projection. A separate read from the settings view: a
1659
+ * restart changes a server's state without touching the User settings
1660
+ * revision a client is holding an `expectedRevision` against.
1661
+ */
1662
+ async loadMcpServers(): Promise<void> {
1663
+ if (!ctx.transport.hostedRequest) return;
1664
+ try {
1665
+ web.value.mcpServers = decodeMcpServerStatusViewV1(
1666
+ await ctx.transport.hostedRequest(MCP_SERVERS_ROUTE),
1667
+ );
1668
+ } catch (error) {
1669
+ web.value.settingsError =
1670
+ error instanceof Error
1671
+ ? error.message
1672
+ : "Could not load the MCP servers";
1673
+ }
1674
+ },
1675
+ async setMcpInstructions(
1676
+ serverId: string,
1677
+ instructions: string,
1678
+ ): Promise<void> {
1679
+ await executeMcpCommand({
1680
+ schemaVersion: 1,
1681
+ type: "mcp/set-instructions",
1682
+ commandId: crypto.randomUUID(),
1683
+ serverId,
1684
+ instructions,
1685
+ });
1686
+ },
1687
+ /**
1688
+ * Connect, or reconnect, an OAuth MCP server.
1689
+ *
1690
+ * The redirect is minted by the host on this authenticated request and
1691
+ * returned to exactly one client, once: it is never read out of a
1692
+ * projection, and nothing stores it. `connectionId` reconnects an existing
1693
+ * Connection — which is what the connect card's *Reconnect* does — and its
1694
+ * absence creates one from the settings given.
1695
+ */
1696
+ async startMcpAuthorization(input: {
1697
+ connectionId?: string;
1698
+ label?: string;
1699
+ settings?: Record<string, unknown>;
1700
+ }): Promise<string | undefined> {
1701
+ if (!ctx.transport.hostedRequest) {
1702
+ throw new Error("MCP authorization is unavailable");
1703
+ }
1704
+ const nativeReturnNonce =
1705
+ "frockbotDesktop" in (globalThis.window ?? {})
1706
+ ? crypto.randomUUID()
1707
+ : undefined;
1708
+ const started = decodeStartConnectionResultV1(
1709
+ await ctx.transport.hostedRequest(
1710
+ MCP_CONNECTIONS_ROUTE,
1711
+ "POST",
1712
+ JSON.stringify({
1713
+ schemaVersion: 1,
1714
+ type: "connection/start",
1715
+ commandId: crypto.randomUUID(),
1716
+ connectionTypeId: MCP_OAUTH_CONNECTION_TYPE_ID,
1717
+ ...(input.connectionId ? { connectionId: input.connectionId } : {}),
1718
+ ...(input.label ? { label: input.label } : {}),
1719
+ ...(input.settings ? { settings: input.settings } : {}),
1720
+ ...(nativeReturnNonce ? { nativeReturnNonce } : {}),
1721
+ }),
1722
+ ),
1723
+ );
1724
+ await web.value.loadUserSettings();
1725
+ await web.value.loadMcpServers();
1726
+ if (started.status === "ready") return undefined;
1727
+ authorizationOperations.set(started.redirectUrl, {
1728
+ ...(started.nativeReturnNonce
1729
+ ? { nativeReturnNonce: started.nativeReturnNonce }
1730
+ : {}),
1731
+ });
1732
+ return started.redirectUrl;
1733
+ },
1734
+ async restartMcpServer(serverId: string): Promise<void> {
1735
+ await executeMcpCommand({
1736
+ schemaVersion: 1,
1737
+ type: "mcp/restart",
1738
+ commandId: crypto.randomUUID(),
1739
+ serverId,
1740
+ });
1741
+ },
1742
+ /**
1743
+ * The remote Catalog index. Read through the gateway route, never from
1744
+ * object storage, and decoded at the seam like every other inbound value.
1745
+ */
1746
+ async loadPackageCatalog(): Promise<void> {
1747
+ if (!ctx.transport.hostedRequest) {
1748
+ updateSettingsLoadError(
1749
+ "package-catalog",
1750
+ "The Catalog is unavailable",
1751
+ );
1752
+ return;
1753
+ }
1754
+ const generation = ++packageCatalogGeneration;
1755
+ try {
1756
+ const index = decodeCatalogIndexV1(
1757
+ await ctx.transport.hostedRequest("/catalog/v1/index"),
1758
+ );
1759
+ if (generation !== packageCatalogGeneration) return;
1760
+ web.value.packageCatalog = index.entries;
1761
+ web.value.packageCatalogGeneration = index.generation;
1762
+ updateSettingsLoadError("package-catalog");
1763
+ } catch (error) {
1764
+ if (generation !== packageCatalogGeneration) return;
1765
+ updateSettingsLoadError(
1766
+ "package-catalog",
1767
+ error instanceof Error ? error.message : "Could not load the Catalog",
1768
+ );
1769
+ }
1770
+ },
1771
+ async loadCatalogEntry(
1772
+ catalogId: string,
1773
+ ): Promise<CatalogEntryV1 | undefined> {
1774
+ if (!ctx.transport.hostedRequest) {
1775
+ throw new Error("The Catalog is unavailable");
1776
+ }
1777
+ // Pinned to the generation the index came from, so an entry never
1778
+ // describes a different generation than the row that opened it.
1779
+ const pinned = web.value.packageCatalogGeneration;
1780
+ return decodeCatalogEntryV1(
1781
+ await ctx.transport.hostedRequest(
1782
+ `/catalog/v1/entry/${encodeURIComponent(catalogId)}${
1783
+ pinned ? `?generation=${encodeURIComponent(pinned)}` : ""
1784
+ }`,
1785
+ ),
1786
+ );
1787
+ },
1788
+ async installCatalogPackage(
1789
+ entry: CatalogIndexEntryV1,
1790
+ values?: Record<string, JsonValue>,
1791
+ ): Promise<void> {
1792
+ const settings = web.value.userSettings;
1793
+ const generation = web.value.packageCatalogGeneration;
1794
+ if (!settings || !ctx.transport.executeConfiguration) {
1795
+ throw new Error("Plugins are unavailable");
1796
+ }
1797
+ if (!generation) throw new Error("The Catalog generation is unknown");
1798
+ const receipt = await ctx.transport.executeConfiguration({
1799
+ schemaVersion: 1,
1800
+ type: "user/install-package",
1801
+ commandId: crypto.randomUUID(),
1802
+ expectedRevision: settings.revision,
1803
+ packageId: entry.packageId,
1804
+ version: entry.version,
1805
+ catalogId: entry.catalogId,
1806
+ catalogGeneration: generation,
1807
+ // GrokBot's `InstallPlugin{values}`: the entry's `setupFields`, filled
1808
+ // in by the User, recorded on the installation so the install is
1809
+ // reproducible from durable state rather than from a form that is gone.
1810
+ ...(values && Object.keys(values).length > 0 ? { values } : {}),
1811
+ });
1812
+ await web.value.loadPluginCatalog();
1813
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
1814
+ },
1815
+ async uninstallPackage(packageId: string): Promise<void> {
1816
+ const settings = web.value.userSettings;
1817
+ if (!settings || !ctx.transport.executeConfiguration) {
1818
+ throw new Error("Plugins are unavailable");
1819
+ }
1820
+ const receipt = await ctx.transport.executeConfiguration({
1821
+ schemaVersion: 1,
1822
+ type: "user/uninstall-package",
1823
+ commandId: crypto.randomUUID(),
1824
+ expectedRevision: settings.revision,
1825
+ packageId,
1826
+ });
1827
+ await web.value.loadPluginCatalog();
1828
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
1829
+ },
1830
+ async setPackageEnabled(
1831
+ packageId: string,
1832
+ enabled: boolean,
1833
+ ): Promise<void> {
1834
+ const settings = web.value.userSettings;
1835
+ if (!settings || !ctx.transport.executeConfiguration) {
1836
+ throw new Error("Plugins are unavailable");
1837
+ }
1838
+ const receipt = await ctx.transport.executeConfiguration({
1839
+ schemaVersion: 1,
1840
+ type: "user/set-package-enabled",
1841
+ commandId: crypto.randomUUID(),
1842
+ expectedRevision: settings.revision,
1843
+ packageId,
1844
+ enabled,
1845
+ });
1846
+ // Enablement is projected onto the installation row the Plugins surface
1847
+ // renders, so the toggle reads back what the User authority recorded.
1848
+ await web.value.loadPluginCatalog();
1849
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
1850
+ },
1851
+ async savePackageSettings(
1852
+ packageId: string,
1853
+ values: Record<string, string | number | boolean>,
1854
+ ): Promise<void> {
1855
+ const settings = web.value.userSettings;
1856
+ if (!settings || !ctx.transport.executeConfiguration) {
1857
+ throw new Error("Plugins are unavailable");
1858
+ }
1859
+ const receipt = await ctx.transport.executeConfiguration({
1860
+ schemaVersion: 1,
1861
+ type: "user/set-package-settings",
1862
+ commandId: crypto.randomUUID(),
1863
+ expectedRevision: settings.revision,
1864
+ packageId,
1865
+ values,
1866
+ });
1867
+ // The values are projected onto the installation row, so the surface
1868
+ // re-reads the User settings it renders the form from.
1869
+ await web.value.loadUserSettings();
1870
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
1871
+ },
1872
+ async installPackage(packageId: string, version: string): Promise<void> {
1873
+ const settings = web.value.userSettings;
1874
+ if (!settings || !ctx.transport.executeConfiguration) {
1875
+ throw new Error("Plugins are unavailable");
1876
+ }
1877
+ await ctx.transport.executeConfiguration({
1878
+ schemaVersion: 1,
1879
+ type: "user/install-package",
1880
+ commandId: crypto.randomUUID(),
1881
+ expectedRevision: settings.revision,
1882
+ packageId,
1883
+ version,
1884
+ });
1885
+ await web.value.loadPluginCatalog();
1886
+ },
1887
+ async startConnection(
1888
+ packageId: string,
1889
+ connectionTypeId: string,
1890
+ ): Promise<string | undefined> {
1891
+ if (!ctx.transport.startConnection) {
1892
+ throw new Error("Connections are unavailable");
1893
+ }
1894
+ const userId = await ctx.transport.readAuthenticatedUserId?.();
1895
+ if (!userId) {
1896
+ throw new Error("Authenticated User identity is unavailable");
1897
+ }
1898
+ const operationKey = JSON.stringify([
1899
+ userId,
1900
+ packageId,
1901
+ connectionTypeId,
1902
+ ]);
1903
+ const operation = await reserveConnectionOperation(
1904
+ connectionOperations,
1905
+ operationKey,
1906
+ () => ({
1907
+ commandId: crypto.randomUUID(),
1908
+ createdAt: Date.now(),
1909
+ ...("frockbotDesktop" in (globalThis.window ?? {})
1910
+ ? { nativeReturnNonce: crypto.randomUUID() }
1911
+ : {}),
1912
+ }),
1913
+ );
1914
+ let result: ClientStartConnectionResult;
1915
+ try {
1916
+ result = await ctx.transport.startConnection({
1917
+ commandId: operation.commandId,
1918
+ packageId,
1919
+ connectionTypeId,
1920
+ nativeReturnNonce: operation.nativeReturnNonce,
1921
+ });
1922
+ } catch (error) {
1923
+ if (isDefinitiveConnectionFailure(error)) {
1924
+ delete connectionOperations[operationKey];
1925
+ writeConnectionOperations(connectionOperations);
1926
+ }
1927
+ throw error;
1928
+ }
1929
+ if (result.status === "ready") {
1930
+ delete connectionOperations[operationKey];
1931
+ writeConnectionOperations(connectionOperations);
1932
+ return undefined;
1933
+ }
1934
+ const expiresAt = Date.parse(result.expiresAt);
1935
+ if (Number.isFinite(expiresAt)) operation.expiresAt = expiresAt;
1936
+ writeConnectionOperations(connectionOperations);
1937
+ authorizationOperations.set(result.redirectUrl, {
1938
+ nativeReturnNonce: result.nativeReturnNonce,
1939
+ });
1940
+ return result.redirectUrl;
1941
+ },
1942
+ async revokeConnection(
1943
+ packageId: string,
1944
+ connectionId: string,
1945
+ ): Promise<void> {
1946
+ if (!ctx.transport.revokeConnection) {
1947
+ throw new Error("Connections are unavailable");
1948
+ }
1949
+ await ctx.transport.revokeConnection(packageId, connectionId);
1950
+ await web.value.loadPluginCatalog();
1951
+ },
1952
+ async createApiKeyConnection(input): Promise<void> {
1953
+ const result = await executeRetainedApiKeyCommand(
1954
+ ["create", input.packageId, input.connectionTypeId, input.label],
1955
+ (commandId) => ({
1956
+ schemaVersion: 1,
1957
+ type: "connection/create-api-key",
1958
+ commandId,
1959
+ ...input,
1960
+ }),
1961
+ );
1962
+ await web.value.loadPluginCatalog();
1963
+ if (result.status !== "applied") {
1964
+ throw new Error("Connection validation failed");
1965
+ }
1966
+ },
1967
+ async createConnection(input): Promise<void> {
1968
+ const result = await executeRetainedApiKeyCommand(
1969
+ ["create", input.packageId, input.connectionTypeId, input.label],
1970
+ (commandId) => ({
1971
+ schemaVersion: 1,
1972
+ type: "connection/create",
1973
+ commandId,
1974
+ ...input,
1975
+ }),
1976
+ );
1977
+ await web.value.loadPluginCatalog();
1978
+ if (result.status !== "applied") {
1979
+ throw new Error("Connection validation failed");
1980
+ }
1981
+ },
1982
+ async rotateApiKeyConnection(connectionId, apiKey): Promise<void> {
1983
+ const connection = web.value.userSettings?.connections.find(
1984
+ (candidate) => candidate.connectionId === connectionId,
1985
+ );
1986
+ if (!connection) {
1987
+ throw new Error("Connection is unavailable");
1988
+ }
1989
+ const result = await executeRetainedApiKeyCommand(
1990
+ ["rotate", connectionId],
1991
+ (commandId) => ({
1992
+ schemaVersion: 1,
1993
+ type: "connection/rotate-api-key",
1994
+ commandId,
1995
+ connectionId,
1996
+ apiKey,
1997
+ }),
1998
+ {
1999
+ packageId: connection.packageId,
2000
+ connectionId,
2001
+ },
2002
+ );
2003
+ await web.value.loadPluginCatalog();
2004
+ if (result.status !== "applied") {
2005
+ throw new Error("Credential validation failed");
2006
+ }
2007
+ },
2008
+ async updateConnectionLabel(connectionId, label): Promise<void> {
2009
+ if (!ctx.transport.executeConnection) {
2010
+ throw new Error("Connections are unavailable");
2011
+ }
2012
+ const result = await ctx.transport.executeConnection({
2013
+ schemaVersion: 1,
2014
+ type: "connection/update-label",
2015
+ commandId: crypto.randomUUID(),
2016
+ connectionId,
2017
+ label,
2018
+ });
2019
+ await web.value.loadPluginCatalog();
2020
+ if (result.status !== "applied") {
2021
+ throw new Error("Connection label update failed");
2022
+ }
2023
+ },
2024
+ async refreshConnectionModels(connectionId): Promise<void> {
2025
+ if (!ctx.transport.executeConnection) {
2026
+ throw new Error("Connections are unavailable");
2027
+ }
2028
+ const result = await ctx.transport.executeConnection({
2029
+ schemaVersion: 1,
2030
+ type: "connection/refresh-models",
2031
+ commandId: crypto.randomUUID(),
2032
+ connectionId,
2033
+ });
2034
+ await web.value.loadPluginCatalog();
2035
+ if (result.status !== "applied") {
2036
+ throw new Error("Model catalog refresh failed");
2037
+ }
2038
+ },
2039
+ async setConnectionEnabled(connectionId, enabled): Promise<void> {
2040
+ if (!ctx.transport.executeConnection) {
2041
+ throw new Error("Connections are unavailable");
2042
+ }
2043
+ const result = await ctx.transport.executeConnection({
2044
+ schemaVersion: 1,
2045
+ type: "connection/set-enabled",
2046
+ commandId: crypto.randomUUID(),
2047
+ connectionId,
2048
+ enabled,
2049
+ });
2050
+ await web.value.loadPluginCatalog();
2051
+ if (result.status !== "applied") {
2052
+ throw new Error("Connection state update failed");
2053
+ }
2054
+ },
2055
+ async disconnectConnection(
2056
+ connectionId,
2057
+ revokeUpstream = false,
2058
+ ): Promise<void> {
2059
+ if (!ctx.transport.executeConnection) {
2060
+ throw new Error("Connections are unavailable");
2061
+ }
2062
+ const result = await ctx.transport.executeConnection({
2063
+ schemaVersion: 1,
2064
+ type: "connection/disconnect",
2065
+ commandId: crypto.randomUUID(),
2066
+ connectionId,
2067
+ revokeUpstream,
2068
+ });
2069
+ await web.value.loadPluginCatalog();
2070
+ if (result.status !== "applied") {
2071
+ throw new Error(
2072
+ result.status === "reconciliation-required"
2073
+ ? "Connection revocation requires reconciliation"
2074
+ : "Connection revocation failed",
2075
+ );
2076
+ }
2077
+ },
2078
+ async openConnectionAuthorization(url: string): Promise<void> {
2079
+ const authorizationUrl = decodeExternalAuthorizationUrl(url);
2080
+ const operation = authorizationOperations.get(authorizationUrl);
2081
+ if (ctx.transport.openExternalAuthorization) {
2082
+ await ctx.transport.openExternalAuthorization(
2083
+ authorizationUrl,
2084
+ operation?.nativeReturnNonce,
2085
+ );
2086
+ if (operation) {
2087
+ authorizationOperations.delete(authorizationUrl);
2088
+ }
2089
+ return;
2090
+ }
2091
+ window.location.assign(authorizationUrl);
2092
+ },
2093
+ async sendPrompt(
2094
+ text: string,
2095
+ skills?: readonly SkillRefV1[],
2096
+ ): Promise<SendPromptResult> {
2097
+ if (web.value.activeRunId) return { accepted: false, error: "busy" };
2098
+ const botId = web.value.activeBotId;
2099
+ if (!botId) return { accepted: false, error: "no-bot" };
2100
+ const generation = selectionGeneration;
2101
+ const pendingRunId = crypto.randomUUID();
2102
+ const optimisticAt = new Date().toISOString();
2103
+ web.value.activeRunId = pendingRunId;
2104
+ web.value.error = undefined;
2105
+ web.value.messages.push(
2106
+ {
2107
+ id: `${pendingRunId}:user`,
2108
+ runId: pendingRunId,
2109
+ role: "user",
2110
+ text,
2111
+ at: optimisticAt,
2112
+ status: "completed",
2113
+ tools: [],
2114
+ sends: [],
2115
+ },
2116
+ {
2117
+ id: `${pendingRunId}:assistant`,
2118
+ runId: pendingRunId,
2119
+ role: "assistant",
2120
+ text: "",
2121
+ at: optimisticAt,
2122
+ status: "streaming",
2123
+ tools: [],
2124
+ sends: [],
2125
+ },
2126
+ );
2127
+ const requestController = new AbortController();
2128
+ activeRequest = requestController;
2129
+ try {
2130
+ if (!web.value.botSettings) await web.value.loadBotSettings();
2131
+ if (
2132
+ requestController.signal.aborted ||
2133
+ generation !== selectionGeneration ||
2134
+ web.value.activeBotId !== botId
2135
+ )
2136
+ return { accepted: true, runId: pendingRunId };
2137
+ const result = await ctx.transport.turn(
2138
+ botId,
2139
+ text,
2140
+ requestController.signal,
2141
+ pendingRunId,
2142
+ skills,
2143
+ );
2144
+ if (
2145
+ generation !== selectionGeneration ||
2146
+ web.value.activeBotId !== botId
2147
+ )
2148
+ return { accepted: true, runId: result.runId };
2149
+ for (const message of web.value.messages) {
2150
+ if (message.runId !== pendingRunId) continue;
2151
+ message.runId = result.runId;
2152
+ message.id = `${result.runId}:${message.role}`;
2153
+ }
2154
+ replaceMessage(web.value.messages, result.runId, {
2155
+ id: `${result.runId}:assistant`,
2156
+ runId: result.runId,
2157
+ role: "assistant",
2158
+ text: result.text,
2159
+ at: optimisticAt,
2160
+ status: "completed",
2161
+ tools: toolsFrom(result.events),
2162
+ sends: sendsFrom(result.events),
2163
+ });
2164
+ try {
2165
+ await deliverNotifications(botId, generation);
2166
+ } catch (error) {
2167
+ if (
2168
+ generation === selectionGeneration &&
2169
+ web.value.activeBotId === botId
2170
+ )
2171
+ web.value.settingsError =
2172
+ error instanceof Error
2173
+ ? error.message
2174
+ : "Notification delivery failed";
2175
+ }
2176
+ return { accepted: true, runId: result.runId };
2177
+ } catch (error) {
2178
+ if (
2179
+ generation !== selectionGeneration ||
2180
+ web.value.activeBotId !== botId
2181
+ )
2182
+ return { accepted: true, runId: pendingRunId };
2183
+ const aborted =
2184
+ error instanceof DOMException && error.name === "AbortError";
2185
+ replaceMessage(web.value.messages, pendingRunId, {
2186
+ id: `${pendingRunId}:assistant`,
2187
+ runId: pendingRunId,
2188
+ role: "assistant",
2189
+ text: aborted
2190
+ ? "Request stopped locally; admission may still be durable."
2191
+ : "Confirming whether this Turn was admitted.",
2192
+ at: optimisticAt,
2193
+ status: "interrupted",
2194
+ tools: [],
2195
+ sends: [],
2196
+ });
2197
+ if (aborted) {
2198
+ web.value.error = undefined;
2199
+ } else {
2200
+ web.value.error =
2201
+ error instanceof Error ? error.message : "Agent request failed";
2202
+ }
2203
+ const observer = new AbortController();
2204
+ admissionObserver = observer;
2205
+ let disposition: "admitted" | "not-admitted" | "detached";
2206
+ try {
2207
+ disposition = await reconcileUncertainAdmission(
2208
+ botId,
2209
+ pendingRunId,
2210
+ observer.signal,
2211
+ );
2212
+ } finally {
2213
+ if (admissionObserver === observer) admissionObserver = undefined;
2214
+ }
2215
+ if (disposition === "not-admitted") {
2216
+ replaceMessage(web.value.messages, pendingRunId, {
2217
+ id: `${pendingRunId}:assistant`,
2218
+ runId: pendingRunId,
2219
+ role: "assistant",
2220
+ text: "Turn was not admitted.",
2221
+ at: optimisticAt,
2222
+ status: "error",
2223
+ tools: [],
2224
+ sends: [],
2225
+ });
2226
+ web.value.error = "Turn was not admitted";
2227
+ return { accepted: false, error: "Turn was not admitted" };
2228
+ }
2229
+ return { accepted: true, runId: pendingRunId };
2230
+ } finally {
2231
+ if (activeRequest === requestController) activeRequest = undefined;
2232
+ if (
2233
+ web.value.activeRunId === pendingRunId &&
2234
+ web.value.activeRun?.runId !== pendingRunId
2235
+ ) {
2236
+ web.value.activeRunId = undefined;
2237
+ }
2238
+ }
2239
+ },
2240
+ async resumeRun(runId: string): Promise<void> {
2241
+ if (!ctx.transport.reconcileRun) {
2242
+ web.value.settingsError = "Turn reconciliation is unavailable";
2243
+ return;
2244
+ }
2245
+ if (web.value.activeRun?.runId !== runId) return;
2246
+ web.value.activeRun = {
2247
+ runId,
2248
+ status: "running",
2249
+ message: "Reconciliation requested; waiting for durable progress.",
2250
+ canResume: false,
2251
+ };
2252
+ const botId = web.value.activeBotId;
2253
+ if (!botId) return;
2254
+ try {
2255
+ await ctx.transport.reconcileRun(botId, runId);
2256
+ } catch (error) {
2257
+ web.value.settingsError =
2258
+ error instanceof Error ? error.message : "Reconciliation failed";
2259
+ }
2260
+ try {
2261
+ await deliverNotifications(botId);
2262
+ } catch (error) {
2263
+ web.value.settingsError =
2264
+ error instanceof Error
2265
+ ? error.message
2266
+ : "Could not refresh the reconciled Turn";
2267
+ }
2268
+ },
2269
+ async stopRun(): Promise<void> {
2270
+ const botId = web.value.activeBotId;
2271
+ const runId = web.value.activeRun?.runId ?? web.value.activeRunId;
2272
+ if (!botId || !runId) return;
2273
+ if (!ctx.transport.stopRun) {
2274
+ web.value.settingsError = "Stop is unavailable";
2275
+ return;
2276
+ }
2277
+ const generation = selectionGeneration;
2278
+ // One durable command per observed run, so repeated Stops replay exactly.
2279
+ const commandId = stopCommands.get(runId) ?? crypto.randomUUID();
2280
+ stopCommands.set(runId, commandId);
2281
+ try {
2282
+ const run = await ctx.transport.stopRun(botId, runId, commandId);
2283
+ if (
2284
+ generation !== selectionGeneration ||
2285
+ web.value.activeBotId !== botId
2286
+ )
2287
+ return;
2288
+ projectDurableRuns(web.value, [], [run]);
2289
+ if (!isTerminalRun(run) && ctx.transport.lookupRun) {
2290
+ runObserver?.abort();
2291
+ const observer = new AbortController();
2292
+ runObserver = observer;
2293
+ try {
2294
+ await observeRunUntilTerminal(
2295
+ botId,
2296
+ runId,
2297
+ generation,
2298
+ observer.signal,
2299
+ );
2300
+ } finally {
2301
+ if (runObserver === observer) runObserver = undefined;
2302
+ }
2303
+ }
2304
+ } catch (error) {
2305
+ if (
2306
+ generation !== selectionGeneration ||
2307
+ web.value.activeBotId !== botId
2308
+ )
2309
+ return;
2310
+ web.value.settingsError =
2311
+ error instanceof Error ? error.message : "Stop failed";
2312
+ }
2313
+ },
2314
+ async abort() {
2315
+ activeRequest?.abort();
2316
+ },
2317
+ });
2318
+
2319
+ return [
2320
+ ctx.provide(clientSurfaceRegistryKey, surfaces),
2321
+ ctx.provide(frockBotWebDataKey, web),
2322
+ ctx.slot({
2323
+ slot: "authenticated-root",
2324
+ order: 10_000,
2325
+ component: FrockBotApp,
2326
+ }),
2327
+ () => {
2328
+ activeRequest?.abort();
2329
+ admissionObserver?.abort();
2330
+ runObserver?.abort();
2331
+ },
2332
+ ];
2333
+ };
2334
+
2335
+ function replaceMessage(
2336
+ messages: WebChatMessage[],
2337
+ runId: string,
2338
+ replacement: WebChatMessage,
2339
+ ): void {
2340
+ const index = messages.findIndex(
2341
+ (message) => message.runId === runId && message.role === "assistant",
2342
+ );
2343
+ if (index >= 0) messages[index] = replacement;
2344
+ }
2345
+
2346
+ export default shellClientPlugin;