@frockbot/plugin-shell 0.1.3 → 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.
Files changed (41) hide show
  1. package/frockbot.json +4 -0
  2. package/package.json +30 -28
  3. package/src/agent.ts +10 -13
  4. package/src/backend-authoring.test.ts +356 -2
  5. package/src/backend-authoring.ts +585 -20
  6. package/src/backend-composition-input.test.ts +65 -0
  7. package/src/backend-composition-input.ts +58 -0
  8. package/src/backend-composition.ts +23 -5
  9. package/src/backend-configuration.test.ts +549 -1468
  10. package/src/backend-contracts.test.ts +28 -0
  11. package/src/backend-contracts.ts +3 -1
  12. package/src/backend-execution.ts +0 -4
  13. package/src/backend-iframe-ui.test.ts +131 -0
  14. package/src/backend-image.test.ts +8 -8
  15. package/src/backend-image.ts +13 -13
  16. package/src/backend-isolate.test.ts +230 -89
  17. package/src/backend-isolate.ts +103 -200
  18. package/src/backend-package-catalog.test.ts +451 -0
  19. package/src/backend-package-catalog.ts +923 -0
  20. package/src/backend-recovery-integration.test.ts +17 -67
  21. package/src/backend-routines.ts +1 -1
  22. package/src/backend-runner-iframe.test.ts +74 -0
  23. package/src/backend-runner.ts +192 -0
  24. package/src/backend.ts +1198 -1781
  25. package/src/client/FrockBotApp.vue +44 -73
  26. package/src/client/PackageIframeHost.vue +218 -0
  27. package/src/client/PackageIframeSettings.vue +52 -0
  28. package/src/client/SendPayloadView.vue +0 -60
  29. package/src/client/index.test.ts +439 -380
  30. package/src/client/index.ts +163 -257
  31. package/src/client/model-presentation.test.ts +21 -8
  32. package/src/client/model-presentation.ts +14 -7
  33. package/src/client/package-iframe-host-message.test.ts +41 -0
  34. package/src/client/package-iframe-host-message.ts +27 -0
  35. package/src/client/styles.css +0 -27
  36. package/src/composition-views.ts +54 -0
  37. package/src/settings-links.test.ts +2 -10
  38. package/src/settings-links.ts +1 -13
  39. package/src/shared.ts +10 -13
  40. package/src/backend-assignment.test.ts +0 -161
  41. package/src/backend-assignment.ts +0 -274
@@ -2,7 +2,6 @@
2
2
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
3
  import {
4
4
  announceUiAnchor,
5
- UiButton,
6
5
  UiIcon,
7
6
  UiIconButton,
8
7
  UiMarkdown,
@@ -24,9 +23,11 @@ import {
24
23
  type WebChatMessage,
25
24
  type WebTaskChip,
26
25
  type WebToolAttachment,
26
+ type WebToolActivity,
27
27
  } from "../shared.js";
28
28
  import { ComposerDraftStore } from "./composer-draft.js";
29
29
  import SendPayloadView from "./SendPayloadView.vue";
30
+ import PackageIframeHost from "./PackageIframeHost.vue";
30
31
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
31
32
  import {
32
33
  nextSkillHighlightV1,
@@ -168,33 +169,6 @@ const botName = computed(
168
169
  );
169
170
  const isRunning = computed(() => Boolean(state.value.activeRunId));
170
171
  const isConnecting = computed(() => state.value.connection !== "ready");
171
- const needsModel = computed(() => state.value.modelSource === "none");
172
- const hasConnectedModelProvider = computed(() => {
173
- const user = state.value.userSettings;
174
- if (!user) return false;
175
- return user.connections.some((connection) => {
176
- if (connection.state !== "ready") return false;
177
- const installed = user.packages.some(
178
- (pkg) =>
179
- pkg.packageId === connection.packageId && pkg.state === "installed",
180
- );
181
- const pkg = state.value.pluginCatalog.find(
182
- (candidate) => candidate.packageId === connection.packageId,
183
- );
184
- const connectionType = pkg?.connectionTypes.find(
185
- (candidate) => candidate.id === connection.connectionTypeId,
186
- );
187
- return Boolean(
188
- installed &&
189
- pkg?.capabilities.some(
190
- (capability) =>
191
- capability.kind === "model" &&
192
- connectionType?.capabilities.includes(capability.id) &&
193
- capability.connectionTypes.includes(connectionType.id),
194
- ),
195
- );
196
- });
197
- });
198
172
  const canSend = computed(
199
173
  () =>
200
174
  state.value.connection === "ready" &&
@@ -217,6 +191,30 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
217
191
  return message.tools.flatMap((tool) => tool.attachments ?? []);
218
192
  }
219
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
+
220
218
  /** The Workspace read route for one encoded `WorkspacePathV1`. */
221
219
  function workspaceFileUrl(path: string): string {
222
220
  const botId = state.value.activeBotId ?? "";
@@ -279,6 +277,7 @@ function isVisible(message: WebChatMessage): boolean {
279
277
  // visible act was dispatching a subagent.
280
278
  return (
281
279
  message.text.length > 0 ||
280
+ message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
282
281
  message.sends.length > 0 ||
283
282
  (message.tasks?.length ?? 0) > 0 ||
284
283
  message.status === "streaming"
@@ -380,17 +379,6 @@ const applySettingsDeepLink = (): void => {
380
379
  if (anchor) void nextTick(() => announceUiAnchor(anchor));
381
380
  };
382
381
 
383
- function openModelSetup(): void {
384
- const registry = surfaces;
385
- if (!registry) return;
386
- if (hasConnectedModelProvider.value && registry.has("user-settings")) {
387
- registry.open("user-settings");
388
- void nextTick(() => announceUiAnchor("user-default-model"));
389
- return;
390
- }
391
- if (registry.has("plugins")) registry.open("plugins");
392
- }
393
-
394
382
  onMounted(() => {
395
383
  void web.value.loadPluginCatalog();
396
384
  void scrollToLatest("auto");
@@ -665,15 +653,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
665
653
  /></span>
666
654
  <div class="workspace-title">
667
655
  <strong>{{ botName }}</strong>
668
- <small v-if="!needsModel">{{ state.modelLabel }}</small>
669
- <button
670
- v-else
671
- type="button"
672
- class="model-setup-link"
673
- @click="openModelSetup"
674
- >
675
- Choose a model
676
- </button>
656
+ <small>{{ state.modelLabel }}</small>
677
657
  </div>
678
658
  <k-slot name="frockbot.header-actions" />
679
659
  </header>
@@ -690,27 +670,16 @@ function handleComposerKeydown(event: KeyboardEvent): void {
690
670
  {{
691
671
  state.modelReady
692
672
  ? `${botName} is ready.`
693
- : needsModel
694
- ? `${botName} needs a model.`
695
- : `${botName} isn't ready.`
673
+ : `${botName} isn't ready.`
696
674
  }}
697
675
  </h1>
698
676
  <p>
699
677
  {{
700
678
  state.modelReady
701
679
  ? "Start with a conversation. Cordis plugins can add the rest."
702
- : needsModel
703
- ? "Pick a default to start chatting."
704
- : "Check this Bot's model Connection."
680
+ : "Check this Bot's model Connection."
705
681
  }}
706
682
  </p>
707
- <UiButton
708
- v-if="needsModel"
709
- variant="primary"
710
- @click="openModelSetup"
711
- >
712
- Choose a model
713
- </UiButton>
714
683
  </div>
715
684
  <article
716
685
  v-for="message in messages"
@@ -745,8 +714,19 @@ function handleComposerKeydown(event: KeyboardEvent): void {
745
714
  <div v-if="message.text" class="message-bubble">
746
715
  <UiMarkdown :text="message.text" />
747
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>
748
728
  <!--
749
- A binary a tool filed in a durable root, drawn from the
729
+ A binary a tool filed in the Workspace, drawn from the
750
730
  Workspace read route. The thread carries the path, never the
751
731
  bytes, so a long conversation costs paths and the image is
752
732
  fetched only when it is on screen.
@@ -894,16 +874,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
894
874
  </span>
895
875
  </li>
896
876
  </ul>
897
- <UiButton
898
- v-if="!isRunning && !isConnecting && needsModel"
899
- type="button"
900
- class="composer-model-setup"
901
- variant="primary"
902
- @click="openModelSetup"
903
- >
904
- Choose a model
905
- </UiButton>
906
- <div v-else class="composer-body">
877
+ <div class="composer-body">
907
878
  <ul v-if="attachedSkills.length > 0" class="skill-chips">
908
879
  <li v-for="entry in attachedSkills" :key="entry.ref">
909
880
  <button
@@ -950,7 +921,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
950
921
  @click="web.stopRun()"
951
922
  />
952
923
  <UiIconButton
953
- v-else-if="!needsModel || isConnecting"
924
+ v-else
954
925
  type="submit"
955
926
  icon="arrow-up"
956
927
  label="Send message"
@@ -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);