@frockbot/plugin-shell 0.1.4 → 0.2.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.
@@ -23,9 +23,11 @@ import {
23
23
  type WebChatMessage,
24
24
  type WebTaskChip,
25
25
  type WebToolAttachment,
26
+ type WebToolActivity,
26
27
  } from "../shared.js";
27
28
  import { ComposerDraftStore } from "./composer-draft.js";
28
29
  import SendPayloadView from "./SendPayloadView.vue";
30
+ import PackageIframeHost from "./PackageIframeHost.vue";
29
31
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
30
32
  import {
31
33
  nextSkillHighlightV1,
@@ -189,6 +191,30 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
189
191
  return message.tools.flatMap((tool) => tool.attachments ?? []);
190
192
  }
191
193
 
194
+ function iframeEntriesFor(tool: WebToolActivity) {
195
+ const slot = `frockbot.tool-result:${tool.name}`;
196
+ return (state.value.packageUi?.contributions ?? [])
197
+ .flatMap((contribution) =>
198
+ contribution.mounts
199
+ .filter((mount) => mount.slot === slot)
200
+ .map((mount) => ({ contribution, slot, order: mount.order ?? 0 })),
201
+ )
202
+ .sort(
203
+ (left, right) =>
204
+ left.order - right.order ||
205
+ left.contribution.packageId.localeCompare(right.contribution.packageId),
206
+ );
207
+ }
208
+
209
+ function toolResultState(tool: WebToolActivity): unknown {
210
+ if (tool.text === undefined) return { status: tool.status };
211
+ try {
212
+ return JSON.parse(tool.text) as unknown;
213
+ } catch {
214
+ return { content: tool.text, isError: tool.status === "failed" };
215
+ }
216
+ }
217
+
192
218
  /** The Workspace read route for one encoded `WorkspacePathV1`. */
193
219
  function workspaceFileUrl(path: string): string {
194
220
  const botId = state.value.activeBotId ?? "";
@@ -251,6 +277,7 @@ function isVisible(message: WebChatMessage): boolean {
251
277
  // visible act was dispatching a subagent.
252
278
  return (
253
279
  message.text.length > 0 ||
280
+ message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
254
281
  message.sends.length > 0 ||
255
282
  (message.tasks?.length ?? 0) > 0 ||
256
283
  message.status === "streaming"
@@ -687,6 +714,17 @@ function handleComposerKeydown(event: KeyboardEvent): void {
687
714
  <div v-if="message.text" class="message-bubble">
688
715
  <UiMarkdown :text="message.text" />
689
716
  </div>
717
+ <template v-for="tool in message.tools" :key="tool.id">
718
+ <PackageIframeHost
719
+ v-for="entry in iframeEntriesFor(tool)"
720
+ :key="`${tool.id}:${entry.contribution.packageId}`"
721
+ class="message-package-iframe"
722
+ :contribution="entry.contribution"
723
+ :slot="entry.slot"
724
+ :state-name="`tool:${tool.name}`"
725
+ :state-value="toolResultState(tool)"
726
+ />
727
+ </template>
690
728
  <!--
691
729
  A binary a tool filed in the Workspace, drawn from the
692
730
  Workspace read route. The thread carries the path, never the
@@ -0,0 +1,218 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ decodePackageIframePageMessageV1,
4
+ packageIframeToolAllowedV1,
5
+ type PackageIframeContributionViewV1,
6
+ type PackageIframeHostMessageV1,
7
+ } from "@frockbot/kernel-contracts";
8
+ import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
9
+ import { frockBotWebDataKey } from "../shared.js";
10
+ import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
11
+
12
+ const props = defineProps<{
13
+ contribution: PackageIframeContributionViewV1;
14
+ slot: string;
15
+ stateName: string;
16
+ stateValue: unknown;
17
+ }>();
18
+ const providedWeb = inject(frockBotWebDataKey);
19
+ if (!providedWeb) throw new Error("Package iframe host data was not provided");
20
+ const web = providedWeb;
21
+ const frame = ref<HTMLIFrameElement>();
22
+ const height = ref(240);
23
+ const failure = ref<string>();
24
+ const lastStateWireByName = new Map<string, string>();
25
+ const catalog = computed(() => web.value.packageUi);
26
+ const source = computed(() => {
27
+ const origin = catalog.value?.artifactOrigin;
28
+ return origin
29
+ ? `${origin}/packages/${props.contribution.artifact.contentHash}.html`
30
+ : "about:blank";
31
+ });
32
+
33
+ const THEME_TOKEN_NAMES = [
34
+ "surface",
35
+ "surface-raised",
36
+ "surface-subtle",
37
+ "text",
38
+ "text-muted",
39
+ "border",
40
+ "accent-surface",
41
+ "accent-text",
42
+ "radius-card",
43
+ ] as const;
44
+
45
+ function themeTokens(): Record<string, string> {
46
+ const styles = getComputedStyle(document.documentElement);
47
+ return Object.fromEntries(
48
+ THEME_TOKEN_NAMES.map((name) => [
49
+ name,
50
+ styles.getPropertyValue(`--frock-${name}`).trim(),
51
+ ]),
52
+ );
53
+ }
54
+
55
+ function post(message: PackageIframeHostMessageV1): void {
56
+ const target = frame.value?.contentWindow;
57
+ if (!target) return;
58
+ try {
59
+ postPackageIframeHostMessage(target, message, lastStateWireByName);
60
+ } catch (error) {
61
+ failure.value =
62
+ error instanceof Error ? error.message : "Package page state is invalid";
63
+ }
64
+ }
65
+
66
+ function initialize(): void {
67
+ const botId = web.value.activeBotId;
68
+ if (!botId) return;
69
+ lastStateWireByName.clear();
70
+ post({
71
+ schemaVersion: 1,
72
+ type: "init",
73
+ themeTokens: themeTokens(),
74
+ packageId: props.contribution.packageId,
75
+ botId,
76
+ slot: props.slot,
77
+ });
78
+ post({
79
+ schemaVersion: 1,
80
+ type: "state",
81
+ name: props.stateName,
82
+ value: props.stateValue,
83
+ });
84
+ }
85
+
86
+ watch(
87
+ () => [props.stateName, props.stateValue] as const,
88
+ () =>
89
+ post({
90
+ schemaVersion: 1,
91
+ type: "state",
92
+ name: props.stateName,
93
+ value: props.stateValue,
94
+ }),
95
+ { deep: true },
96
+ );
97
+
98
+ async function onMessage(event: MessageEvent): Promise<void> {
99
+ if (!frame.value?.contentWindow || event.source !== frame.value.contentWindow)
100
+ return;
101
+ let message;
102
+ try {
103
+ message = decodePackageIframePageMessageV1(event.data);
104
+ } catch {
105
+ return;
106
+ }
107
+ if (message.type === "resize") {
108
+ height.value = Math.min(1_200, Math.max(96, Math.round(message.height)));
109
+ return;
110
+ }
111
+ if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
112
+ failure.value = `This Package did not declare ${message.name}.`;
113
+ return;
114
+ }
115
+ failure.value = undefined;
116
+ try {
117
+ const result = await web.value.callPackageUiTool(
118
+ props.contribution,
119
+ message.name,
120
+ message.input,
121
+ );
122
+ post({
123
+ schemaVersion: 1,
124
+ type: "state",
125
+ name: `tool:${message.name}`,
126
+ value: result,
127
+ });
128
+ } catch (error) {
129
+ failure.value = error instanceof Error ? error.message : "Tool call failed";
130
+ post({
131
+ schemaVersion: 1,
132
+ type: "state",
133
+ name: `tool:${message.name}`,
134
+ value: { isError: true, content: failure.value },
135
+ });
136
+ }
137
+ }
138
+
139
+ onMounted(() => window.addEventListener("message", onMessage));
140
+ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
141
+ </script>
142
+
143
+ <template>
144
+ <section class="package-iframe-frame">
145
+ <header class="package-iframe-attribution">
146
+ <strong>{{ contribution.displayName }}</strong>
147
+ <span>{{ contribution.provenance }} Package</span>
148
+ </header>
149
+ <!-- Load eagerly because lazy iframes defer the init/resize handshake until the browser decides the frame is near the viewport, which headless Chromium may never do. -->
150
+ <iframe
151
+ ref="frame"
152
+ :title="`${contribution.displayName} Package page`"
153
+ :src="source"
154
+ :style="{ height: `${height}px` }"
155
+ sandbox="allow-scripts"
156
+ credentialless
157
+ referrerpolicy="no-referrer"
158
+ @load="initialize"
159
+ />
160
+ <p v-if="failure" class="package-iframe-failure" role="alert">
161
+ {{ failure }}
162
+ </p>
163
+ </section>
164
+ </template>
165
+
166
+ <style scoped>
167
+ .package-iframe-frame {
168
+ min-width: 0;
169
+ overflow: hidden;
170
+ border: 1px solid var(--frock-border);
171
+ border-radius: var(--frock-radius-card);
172
+ background: var(--frock-surface);
173
+ }
174
+
175
+ .package-iframe-attribution {
176
+ position: relative;
177
+ z-index: 1;
178
+ display: flex;
179
+ align-items: baseline;
180
+ justify-content: space-between;
181
+ gap: 12px;
182
+ min-height: 36px;
183
+ padding: 8px 12px;
184
+ border-bottom: 1px solid var(--frock-border);
185
+ color: var(--frock-text);
186
+ background: var(--frock-surface-subtle);
187
+ font-size: var(--frock-text-xs);
188
+ }
189
+
190
+ .package-iframe-attribution span,
191
+ .package-iframe-failure {
192
+ color: var(--frock-text-muted);
193
+ }
194
+
195
+ iframe {
196
+ display: block;
197
+ width: 100%;
198
+ max-width: 100%;
199
+ border: 0;
200
+ background: transparent;
201
+ }
202
+
203
+ .package-iframe-failure {
204
+ margin: 0;
205
+ padding: 8px 12px;
206
+ border-top: 1px solid var(--frock-danger-border);
207
+ color: var(--frock-danger-text);
208
+ font-size: var(--frock-text-xs);
209
+ }
210
+
211
+ @media (max-width: 640px) {
212
+ .package-iframe-attribution {
213
+ align-items: flex-start;
214
+ flex-direction: column;
215
+ gap: 2px;
216
+ }
217
+ }
218
+ </style>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject } from "vue";
3
+ import { frockBotWebDataKey } from "../shared.js";
4
+ import PackageIframeHost from "./PackageIframeHost.vue";
5
+
6
+ const providedWeb = inject(frockBotWebDataKey);
7
+ if (!providedWeb)
8
+ throw new Error("Package iframe settings data was not provided");
9
+ const web = providedWeb;
10
+ const slot = "frockbot.bot-settings-sections";
11
+ const contributions = computed(() =>
12
+ (web.value.packageUi?.contributions ?? [])
13
+ .flatMap((contribution) =>
14
+ contribution.mounts
15
+ .filter((mount) => mount.slot === slot)
16
+ .map((mount) => ({ contribution, order: mount.order ?? 0 })),
17
+ )
18
+ .sort(
19
+ (left, right) =>
20
+ left.order - right.order ||
21
+ left.contribution.packageId.localeCompare(right.contribution.packageId),
22
+ ),
23
+ );
24
+
25
+ function settingsFor(packageId: string): Record<string, unknown> {
26
+ const installation = web.value.userSettings?.packages.find(
27
+ (candidate) => candidate.packageId === packageId,
28
+ );
29
+ return installation?.values ?? {};
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div v-if="contributions.length > 0" class="package-iframe-settings">
35
+ <PackageIframeHost
36
+ v-for="entry in contributions"
37
+ :key="entry.contribution.packageId"
38
+ :contribution="entry.contribution"
39
+ :slot="slot"
40
+ state-name="settings"
41
+ :state-value="settingsFor(entry.contribution.packageId)"
42
+ />
43
+ </div>
44
+ </template>
45
+
46
+ <style scoped>
47
+ .package-iframe-settings {
48
+ display: grid;
49
+ gap: 16px;
50
+ min-width: 0;
51
+ }
52
+ </style>
@@ -19,7 +19,6 @@
19
19
  */
20
20
  import { UiButton, UiMarkdown } from "@frockbot/client-ui";
21
21
  import { computed, inject, ref } from "vue";
22
- import { settingsLinkV1 } from "../settings-links.js";
23
22
  import { frockBotWebDataKey, type WebSendPayload } from "../shared.js";
24
23
 
25
24
  const props = defineProps<{ send: WebSendPayload }>();
@@ -39,17 +38,6 @@ const widget = computed(() =>
39
38
  const attachment = computed(() =>
40
39
  payload.value?.type === "attachment" ? payload.value : undefined,
41
40
  );
42
- /**
43
- * The connect card. It carries no URL and never will: the Bot recorded a
44
- * pending decision, and only the User — in Settings, where the host authors
45
- * the link at the moment they press it — can complete one. So this draws the
46
- * request and points at the place the decision is made, and is deliberately
47
- * not a button that authorizes anything.
48
- */
49
- const connectCard = computed(() =>
50
- payload.value?.type === "connect-card" ? payload.value : undefined,
51
- );
52
- const connectionsLink = settingsLinkV1({ anchor: "user-packages" });
53
41
  const approval = computed(() =>
54
42
  payload.value?.type === "approval" ? payload.value : undefined,
55
43
  );
@@ -115,21 +103,6 @@ const unsupported = computed(
115
103
  </p>
116
104
  </section>
117
105
 
118
- <section
119
- v-else-if="connectCard"
120
- class="send-connect-card"
121
- aria-label="Connection request"
122
- >
123
- <p class="send-connect-title">{{ connectCard.title }}</p>
124
- <p v-if="connectCard.body" class="send-connect-body">
125
- {{ connectCard.body }}
126
- </p>
127
- <p class="send-connect-help">
128
- Open Settings → Plugins to authorize it. Only you can complete this.
129
- <a :href="connectionsLink">{{ connectionsLink }}</a>
130
- </p>
131
- </section>
132
-
133
106
  <section
134
107
  v-else-if="approval"
135
108
  class="send-approval"
@@ -221,39 +194,6 @@ const unsupported = computed(
221
194
  list-style: none;
222
195
  }
223
196
 
224
- .send-connect-card {
225
- display: flex;
226
- flex-direction: column;
227
- gap: 0.5rem;
228
- border: 1px solid var(--frock-border);
229
- border-radius: var(--frock-radius-card);
230
- background: var(--frock-surface-raised);
231
- padding: 0.75rem 1rem;
232
- }
233
-
234
- .send-connect-title {
235
- margin: 0;
236
- color: var(--frock-text);
237
- font-weight: 600;
238
- font-size: var(--frock-text-base);
239
- line-height: var(--frock-leading-snug);
240
- }
241
-
242
- .send-connect-body {
243
- margin: 0;
244
- color: var(--frock-text);
245
- font-size: var(--frock-text-sm);
246
- line-height: var(--frock-leading-normal);
247
- }
248
-
249
- .send-connect-help {
250
- margin: 0;
251
- color: var(--frock-text-muted);
252
- font-size: var(--frock-text-sm);
253
- line-height: var(--frock-leading-normal);
254
- overflow-wrap: anywhere;
255
- }
256
-
257
197
  .send-widget-options li {
258
198
  border: 1px solid var(--frock-border);
259
199
  border-radius: var(--frock-radius-control);
@@ -395,6 +395,125 @@ describe("Bot selection", () => {
395
395
  expect(provided.value.activeBotId).toBe("new");
396
396
  expect(provided.value.botSettings?.botId).toBe("new");
397
397
  });
398
+
399
+ test("loads sandboxed Package UI and transports only declared iframe tools", async () => {
400
+ Object.defineProperty(globalThis, "window", {
401
+ configurable: true,
402
+ value: {
403
+ location: { href: "https://app.example/" },
404
+ history: { replaceState: () => undefined },
405
+ },
406
+ });
407
+ const contribution = {
408
+ packageId: "weather-page",
409
+ displayName: "Sydney Weather",
410
+ provenance: "Bot-authored" as const,
411
+ artifact: {
412
+ contentHash: "a".repeat(64),
413
+ size: 1,
414
+ mediaType: "text/html" as const,
415
+ bundlerVersion: "frockbot-inline-html@1",
416
+ },
417
+ mounts: [{ slot: "frockbot.bot-settings-sections", order: 20 }],
418
+ declaredTools: ["weather_lookup"],
419
+ };
420
+ const requests: Array<{
421
+ path: string;
422
+ method?: string;
423
+ body?: string;
424
+ }> = [];
425
+ const slots: string[] = [];
426
+ let provided: Ref<FrockBotWebData> | undefined;
427
+ await shellClientPlugin({
428
+ transport: {
429
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
430
+ readConfiguration: (query) => {
431
+ if (query.type !== "bot/get")
432
+ throw new Error("unexpected User query");
433
+ return Promise.resolve(initializeBotSettingsV1(query.botId));
434
+ },
435
+ hostedRequest: (path, method, body) => {
436
+ requests.push({ path, method, body });
437
+ if (path.endsWith("/package-ui")) {
438
+ return Promise.resolve({
439
+ schemaVersion: 1,
440
+ botId: "primary",
441
+ generationId: "generation-ui",
442
+ artifactOrigin: "https://ui.app.example",
443
+ contributions: [contribution],
444
+ });
445
+ }
446
+ if (path.endsWith("/package-ui/tools")) {
447
+ return Promise.resolve({
448
+ schemaVersion: 1,
449
+ runId: "run-iframe-tool",
450
+ text: "Sydney Weather",
451
+ events: [],
452
+ });
453
+ }
454
+ return Promise.resolve({});
455
+ },
456
+ },
457
+ slot: (registration) => {
458
+ slots.push(registration.slot);
459
+ return () => {};
460
+ },
461
+ inject: () => {
462
+ throw new Error("unexpected client provider injection");
463
+ },
464
+ provide: (_key, value) => {
465
+ provided = value as Ref<FrockBotWebData>;
466
+ return () => {};
467
+ },
468
+ });
469
+ if (!provided) throw new Error("shell data was not provided");
470
+ provided.value.userSettings = {
471
+ schemaVersion: 1,
472
+ revision: 1,
473
+ profile: { name: "User" },
474
+ packages: [],
475
+ connections: [],
476
+ };
477
+
478
+ await provided.value.selectBot("primary");
479
+
480
+ expect(slots).toContain("frockbot.bot-settings-sections");
481
+ expect(requests.filter(({ path }) => path.endsWith("/package-ui"))).toEqual(
482
+ [
483
+ {
484
+ path: "/api/bots/primary/package-ui",
485
+ method: undefined,
486
+ body: undefined,
487
+ },
488
+ ],
489
+ );
490
+ expect(provided.value.packageUi?.contributions).toEqual([contribution]);
491
+
492
+ expect(
493
+ await provided.value.callPackageUiTool(contribution, "weather_lookup", {
494
+ city: "Sydney",
495
+ }),
496
+ ).toEqual({ content: "Sydney Weather", isError: false });
497
+ const toolRequest = requests.find(({ path }) =>
498
+ path.endsWith("/package-ui/tools"),
499
+ );
500
+ expect(toolRequest?.method).toBe("POST");
501
+ expect(JSON.parse(toolRequest?.body ?? "null")).toEqual({
502
+ schemaVersion: 1,
503
+ commandId: expect.any(String),
504
+ generationId: "generation-ui",
505
+ packageId: "weather-page",
506
+ name: "weather_lookup",
507
+ input: { city: "Sydney" },
508
+ });
509
+
510
+ const requestCount = requests.length;
511
+ await expect(
512
+ provided.value.callPackageUiTool(contribution, "package_author", {}),
513
+ ).rejects.toThrow("did not declare");
514
+ expect(requests).toHaveLength(requestCount);
515
+ });
516
+
398
517
  test("preserves load failures and ignores stale User settings", async () => {
399
518
  let provided: Ref<FrockBotWebData> | undefined;
400
519
  const older = Promise.withResolvers<UserSettingsViewV1>();
@@ -17,7 +17,12 @@ import type {
17
17
  ConnectionCommandV1,
18
18
  } from "@frockbot/connection-core";
19
19
  import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
20
- import { decodeSendToUserPayloadV1 } from "@frockbot/kernel-contracts";
20
+ import {
21
+ decodePackageIframeCatalogV1,
22
+ packageIframeToolAllowedV1,
23
+ decodeSendToUserPayloadV1,
24
+ type PackageIframeContributionViewV1,
25
+ } from "@frockbot/kernel-contracts";
21
26
  import { createClientSurfaceRegistry } from "@frockbot/client-ui";
22
27
  import type {
23
28
  BotNameProvenanceV1,
@@ -48,6 +53,7 @@ import {
48
53
  import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
49
54
  import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
50
55
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
56
+ import { decodeClientTurnV1 } from "../run-protocol.js";
51
57
  import {
52
58
  decodeApprovalDecisionReceiptV1,
53
59
  decodeApprovalListViewV1,
@@ -69,6 +75,7 @@ import {
69
75
  type WebToolActivity,
70
76
  } from "../shared.js";
71
77
  import FrockBotApp from "./FrockBotApp.vue";
78
+ import PackageIframeSettings from "./PackageIframeSettings.vue";
72
79
  import { modelRuntimeLabel } from "./model-presentation.js";
73
80
  import { showClientNotificationV1 } from "./notify.js";
74
81
  import "@frockbot/client-core/fonts.css";
@@ -1095,6 +1102,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1095
1102
  skillCatalog: [],
1096
1103
  approvals: [],
1097
1104
  tasks: [],
1105
+ packageUi: undefined,
1098
1106
  async selectBot(botId: string): Promise<void> {
1099
1107
  // Re-selecting the open Bot is not a switch: aborting the live Turn and
1100
1108
  // clearing the transcript would discard state the User is watching.
@@ -1113,6 +1121,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1113
1121
  web.value.skillCatalog = [];
1114
1122
  web.value.approvals = [];
1115
1123
  web.value.tasks = [];
1124
+ web.value.packageUi = undefined;
1116
1125
  const url = URL.parse(window.location.href);
1117
1126
  if (url) {
1118
1127
  url.searchParams.set("bot", botId);
@@ -1196,6 +1205,68 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1196
1205
  web.value.tasks = [];
1197
1206
  }
1198
1207
  },
1208
+ async loadPackageUi(): Promise<void> {
1209
+ const read = ctx.transport.hostedRequest;
1210
+ const botId = web.value.activeBotId;
1211
+ if (!read || !botId) return;
1212
+ const generation = selectionGeneration;
1213
+ try {
1214
+ const catalog = decodePackageIframeCatalogV1(
1215
+ await read(`/api/bots/${encodeURIComponent(botId)}/package-ui`),
1216
+ );
1217
+ if (
1218
+ generation !== selectionGeneration ||
1219
+ web.value.activeBotId !== botId
1220
+ )
1221
+ return;
1222
+ web.value.packageUi = catalog;
1223
+ } catch {
1224
+ if (
1225
+ generation === selectionGeneration &&
1226
+ web.value.activeBotId === botId
1227
+ ) {
1228
+ web.value.packageUi = undefined;
1229
+ }
1230
+ }
1231
+ },
1232
+ async callPackageUiTool(
1233
+ contribution: PackageIframeContributionViewV1,
1234
+ name: string,
1235
+ input: unknown,
1236
+ ): Promise<unknown> {
1237
+ const post = ctx.transport.hostedRequest;
1238
+ const botId = web.value.activeBotId;
1239
+ const catalog = web.value.packageUi;
1240
+ if (!post || !botId || !catalog || catalog.botId !== botId) {
1241
+ throw new Error("Package UI is unavailable");
1242
+ }
1243
+ if (!packageIframeToolAllowedV1(contribution, name)) {
1244
+ throw new Error(
1245
+ `Package "${contribution.packageId}" did not declare tool "${name}"`,
1246
+ );
1247
+ }
1248
+ const turn = decodeClientTurnV1(
1249
+ await post(
1250
+ `/api/bots/${encodeURIComponent(botId)}/package-ui/tools`,
1251
+ "POST",
1252
+ JSON.stringify({
1253
+ schemaVersion: 1,
1254
+ commandId: crypto.randomUUID(),
1255
+ generationId: catalog.generationId,
1256
+ packageId: contribution.packageId,
1257
+ name,
1258
+ input,
1259
+ }),
1260
+ ),
1261
+ );
1262
+ await deliverNotifications(botId);
1263
+ const result = turn.events.findLast(
1264
+ (event) => event.type === "tool/result",
1265
+ );
1266
+ return result?.type === "tool/result"
1267
+ ? { content: result.content, isError: result.isError === true }
1268
+ : { content: turn.text, isError: false };
1269
+ },
1199
1270
  /**
1200
1271
  * Explicit, authenticated cancellation of one subagent from the client.
1201
1272
  *
@@ -1288,6 +1359,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1288
1359
  updateModelLabel();
1289
1360
  updateSettingsLoadError("bot");
1290
1361
  await deliverNotifications(botId, generation);
1362
+ await web.value.loadPackageUi();
1291
1363
  } catch (error) {
1292
1364
  if (
1293
1365
  generation !== selectionGeneration ||
@@ -2153,6 +2225,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2153
2225
  order: 10_000,
2154
2226
  component: FrockBotApp,
2155
2227
  }),
2228
+ ctx.slot({
2229
+ slot: "frockbot.bot-settings-sections",
2230
+ order: 10_000,
2231
+ component: PackageIframeSettings,
2232
+ }),
2156
2233
  () => {
2157
2234
  activeRequest?.abort();
2158
2235
  admissionObserver?.abort();