@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
@@ -1,19 +1,26 @@
1
1
  /**
2
- * The model line the shell shows above the composer. It names the model the
3
- * Bot actually runs on, whether that model is the Bot's own override or the
4
- * User's default: a Bot that "just works" does not advertise where its
5
- * settings came from.
2
+ * The model line the shell shows above the composer. Platform choices read as
3
+ * the model itself; opting into an account choice or Bot override makes that
4
+ * distinction visible. A resolver failure is already the backend's complete,
5
+ * repairable explanation, so the client presents it verbatim.
6
6
  */
7
7
  export function modelRuntimeLabel(input: {
8
+ source: "bot" | "account" | "platform" | "none";
8
9
  modelDisplayName?: string;
9
10
  providerModelId?: string;
10
11
  packageDisplayName?: string;
11
12
  connectionDisplayName?: string;
12
- hasModel: boolean;
13
+ failure?: string;
13
14
  }): string {
14
- if (!input.hasModel) return "No default model";
15
+ if (input.failure) return input.failure;
16
+ if (input.source === "none" || !input.providerModelId) {
17
+ return "Model unavailable";
18
+ }
15
19
  const model =
16
20
  input.modelDisplayName ?? input.providerModelId ?? "Connected model";
17
21
  const provider = input.packageDisplayName ?? input.connectionDisplayName;
18
- return provider ? `${model} · ${provider}` : model;
22
+ const runtime = provider ? `${model} · ${provider}` : model;
23
+ if (input.source === "bot") return `${runtime} · Bot override`;
24
+ if (input.source === "account") return `${runtime} · Account model`;
25
+ return runtime;
19
26
  }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PackageIframeHostMessageV1 } from "@frockbot/kernel-contracts";
3
+ import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
4
+
5
+ describe("Package iframe host messages", () => {
6
+ test("posts unchanged state once and posts changed state again", () => {
7
+ const posted: PackageIframeHostMessageV1[] = [];
8
+ const target = {
9
+ postMessage(message: PackageIframeHostMessageV1): void {
10
+ posted.push(message);
11
+ },
12
+ } as Pick<Window, "postMessage">;
13
+ const lastStateWireByName = new Map<string, string>();
14
+
15
+ const postSettings = (value: unknown): void =>
16
+ postPackageIframeHostMessage(
17
+ target,
18
+ { schemaVersion: 1, type: "state", name: "settings", value },
19
+ lastStateWireByName,
20
+ );
21
+
22
+ postSettings({ temperatureUnit: "celsius" });
23
+ postSettings({ temperatureUnit: "celsius" });
24
+ postSettings({ temperatureUnit: "fahrenheit" });
25
+
26
+ expect(posted).toEqual([
27
+ {
28
+ schemaVersion: 1,
29
+ type: "state",
30
+ name: "settings",
31
+ value: { temperatureUnit: "celsius" },
32
+ },
33
+ {
34
+ schemaVersion: 1,
35
+ type: "state",
36
+ name: "settings",
37
+ value: { temperatureUnit: "fahrenheit" },
38
+ },
39
+ ]);
40
+ });
41
+ });
@@ -0,0 +1,27 @@
1
+ import type { PackageIframeHostMessageV1 } from "@frockbot/kernel-contracts";
2
+
3
+ type MessageTarget = Pick<Window, "postMessage">;
4
+
5
+ export function postPackageIframeHostMessage(
6
+ target: MessageTarget,
7
+ message: PackageIframeHostMessageV1,
8
+ lastStateWireByName: Map<string, string>,
9
+ ): void {
10
+ const wire = JSON.stringify(message);
11
+ if (new TextEncoder().encode(wire).byteLength > 64 * 1024) {
12
+ throw new Error("Package page state exceeds the bridge limit");
13
+ }
14
+ if (
15
+ message.type === "state" &&
16
+ lastStateWireByName.get(message.name) === wire
17
+ ) {
18
+ return;
19
+ }
20
+
21
+ // Vue settings values may be reactive proxies, which structured clone
22
+ // rejects. JSON is also the bridge's declared value domain.
23
+ target.postMessage(JSON.parse(wire) as PackageIframeHostMessageV1, "*");
24
+ if (message.type === "state") {
25
+ lastStateWireByName.set(message.name, wire);
26
+ }
27
+ }
@@ -175,27 +175,6 @@
175
175
  text-overflow: ellipsis;
176
176
  }
177
177
 
178
- .model-setup-link {
179
- width: max-content;
180
- max-width: 100%;
181
- margin-top: 3px;
182
- overflow: hidden;
183
- border: 0;
184
- padding: 0;
185
- color: var(--frock-action-primary);
186
- background: transparent;
187
- font: inherit;
188
- font-size: var(--frock-text-sm);
189
- text-align: left;
190
- text-overflow: ellipsis;
191
- white-space: nowrap;
192
- cursor: pointer;
193
- }
194
-
195
- .model-setup-link:hover {
196
- color: var(--frock-action-primary-hover);
197
- }
198
-
199
178
  /*
200
179
  * The panel collapse control is pinned to the window's trailing edge. Feature
201
180
  * actions live in the panel's own header.
@@ -570,12 +549,6 @@
570
549
  0 0 0 3px var(--frock-focus-ring);
571
550
  }
572
551
 
573
- .composer-model-setup {
574
- min-width: 0;
575
- flex: 1;
576
- align-self: center;
577
- }
578
-
579
552
  .composer textarea {
580
553
  box-sizing: border-box;
581
554
  width: 100%;
@@ -9,6 +9,11 @@ import {
9
9
  type CompositionMemberViewV1,
10
10
  type CompositionProvenanceViewV1,
11
11
  } from "@frockbot/configuration-core";
12
+ import type { PackageIframeCompositionV1 } from "@frockbot/kernel-contracts";
13
+ import {
14
+ isClientIframeContribution,
15
+ type FrockBotManifest,
16
+ } from "@frockbot/kernel-composition";
12
17
  import type {
13
18
  CompositionFailureV1,
14
19
  CompositionQuarantineV1,
@@ -29,11 +34,57 @@ export type CompositionMemberSourceReaderV1 = (
29
34
  member: CompositionMemberV1,
30
35
  ) => Promise<string | undefined>;
31
36
 
37
+ /** One decoded manifest lookup shared by isolate mount and hosted UI views. */
38
+ export type CompositionMemberManifestReaderV1 = (
39
+ member: CompositionMemberV1,
40
+ ) => Promise<FrockBotManifest | undefined>;
41
+
42
+ /** Project iframe Contributions from either Bot or Catalog provenance. */
43
+ export async function projectPackageIframeCompositionV1(input: {
44
+ botId: string;
45
+ generation: CompositionGenerationV1;
46
+ readMemberManifest: CompositionMemberManifestReaderV1;
47
+ }): Promise<PackageIframeCompositionV1> {
48
+ const contributions: PackageIframeCompositionV1["contributions"] = [];
49
+ for (const member of input.generation.members) {
50
+ if (member.provenance.kind === "first-party") continue;
51
+ const manifest = await input.readMemberManifest(member);
52
+ if (!manifest) continue;
53
+ const client = manifest.contributions.client;
54
+ if (!client || !isClientIframeContribution(client)) continue;
55
+ contributions.push({
56
+ packageId: member.packageId,
57
+ displayName: manifest.displayName,
58
+ provenance:
59
+ member.provenance.kind === "bot" ? "Bot-authored" : "User-installed",
60
+ artifact: { ...client.artifact },
61
+ mounts: client.mounts.map((mount) => ({ ...mount })),
62
+ declaredTools: (manifest.tools ?? []).map((tool) => tool.name),
63
+ });
64
+ }
65
+ contributions.sort((left, right) =>
66
+ left.packageId.localeCompare(right.packageId),
67
+ );
68
+ return {
69
+ schemaVersion: 1,
70
+ botId: input.botId,
71
+ generationId: input.generation.generationId,
72
+ contributions,
73
+ };
74
+ }
75
+
32
76
  function provenanceView(
33
77
  member: CompositionMemberV1,
34
78
  ): CompositionProvenanceViewV1 {
35
79
  const provenance = member.provenance;
36
80
  if (provenance.kind === "first-party") return { kind: "first-party" };
81
+ if (provenance.kind === "catalog") {
82
+ return {
83
+ kind: "catalog",
84
+ catalogId: provenance.catalogId,
85
+ catalogGeneration: provenance.catalogGeneration,
86
+ };
87
+ }
37
88
  if (provenance.kind === "user") {
38
89
  return {
39
90
  kind: "user",
@@ -102,6 +153,9 @@ export async function projectCompositionGenerationV1(
102
153
  isCurrent: input.generation.generationId === input.currentGenerationId,
103
154
  members,
104
155
  failures,
156
+ ...(input.generation.summary === undefined
157
+ ? {}
158
+ : { summary: input.generation.summary }),
105
159
  ...(input.quarantine === undefined
106
160
  ? {}
107
161
  : {
@@ -23,11 +23,11 @@ describe("settings link scheme", () => {
23
23
  test("renders against an origin when one is supplied", () => {
24
24
  expect(
25
25
  settingsLinkV1({
26
- anchor: "bot-model",
26
+ anchor: "bot-name",
27
27
  botId: "alpha",
28
28
  origin: "https://app.example/",
29
29
  }),
30
- ).toBe("https://app.example/?bot=alpha&settings=bot-settings#bot-model");
30
+ ).toBe("https://app.example/?bot=alpha&settings=bot-settings#bot-name");
31
31
  });
32
32
 
33
33
  test("refuses an anchor this build does not ship", () => {
@@ -36,14 +36,6 @@ describe("settings link scheme", () => {
36
36
  );
37
37
  });
38
38
 
39
- test("renders a Markdown citation a payload can carry verbatim", () => {
40
- expect(
41
- renderSettingsLinkV1({ anchor: "bot-capabilities", botId: "alpha" }),
42
- ).toBe(
43
- "[Capability Assignments](/?bot=alpha&settings=bot-settings#bot-capabilities)",
44
- );
45
- });
46
-
47
39
  test("decodes an absolute link back to its surface, anchor and Bot", () => {
48
40
  expect(
49
41
  decodeSettingsLinkV1(
@@ -108,18 +108,6 @@ export const SETTINGS_ANCHORS_V1: readonly SettingsAnchorV1[] = [
108
108
  label: "Waiting on you",
109
109
  scope: "bot",
110
110
  },
111
- {
112
- anchor: "bot-model",
113
- surface: "bot-settings",
114
- label: "Model",
115
- scope: "bot",
116
- },
117
- {
118
- anchor: "bot-capabilities",
119
- surface: "bot-settings",
120
- label: "Capability Assignments",
121
- scope: "bot",
122
- },
123
111
  {
124
112
  anchor: "bot-routines",
125
113
  surface: "bot-settings",
@@ -194,7 +182,7 @@ export const SETTINGS_ANCHORS_V1: readonly SettingsAnchorV1[] = [
194
182
  {
195
183
  anchor: "user-connections",
196
184
  surface: "connections",
197
- label: "Connections",
185
+ label: "Connectors",
198
186
  scope: "user",
199
187
  },
200
188
  {
package/src/shared.ts CHANGED
@@ -7,8 +7,6 @@ import type {
7
7
  BotProfile,
8
8
  BotProfilePatchV1,
9
9
  BotSettingsViewV1,
10
- CapabilityAssignmentView,
11
- ModelAssignment,
12
10
  UserSettingsViewV1,
13
11
  } from "@frockbot/configuration-core";
14
12
  import type {
@@ -16,6 +14,8 @@ import type {
16
14
  CatalogIndexEntryV1,
17
15
  } from "@frockbot/catalog-core";
18
16
  import type {
17
+ PackageIframeCatalogV1,
18
+ PackageIframeContributionViewV1,
19
19
  SendToUserPayloadV1,
20
20
  SkillRefV1,
21
21
  } from "@frockbot/kernel-contracts";
@@ -189,6 +189,8 @@ export interface FrockBotWebData {
189
189
  * without the run's own events being rewritten.
190
190
  */
191
191
  tasks: TaskViewV1[];
192
+ /** Sandboxed pages in the selected Bot's active fail-closed Composition. */
193
+ packageUi?: PackageIframeCatalogV1;
192
194
  /**
193
195
  * The User's MCP servers: state, tool count, last handshake, instructions,
194
196
  * failure, and the durable refusal ledger. Absent until it is loaded, and
@@ -206,19 +208,8 @@ export interface FrockBotWebData {
206
208
  namedBy?: BotNameProvenanceV1,
207
209
  ): Promise<void>;
208
210
  saveBotNotifications(notifications: BotNotificationPolicy): Promise<void>;
209
- assignCapability(
210
- assignment: Omit<CapabilityAssignmentView, "state">,
211
- ): Promise<void>;
212
- replaceCapability(
213
- assignment: Omit<CapabilityAssignmentView, "state">,
214
- ): Promise<void>;
215
- unassignCapability(assignmentId: string): Promise<void>;
216
- saveBotModel(model: ModelAssignment): Promise<void>;
217
- clearBotModel(): Promise<void>;
218
211
  loadUserSettings(): Promise<void>;
219
212
  saveUserProfile(profile: { name: string; email?: string }): Promise<void>;
220
- /** The model every Bot uses unless it overrides it. */
221
- saveDefaultModel(model: ModelAssignment | undefined): Promise<void>;
222
213
  loadPluginCatalog(): Promise<void>;
223
214
  /** Refreshes {@link FrockBotWebData.mcpServers}. */
224
215
  loadMcpServers(): Promise<void>;
@@ -253,6 +244,12 @@ export interface FrockBotWebData {
253
244
  loadApprovals(): Promise<void>;
254
245
  /** Refreshes {@link FrockBotWebData.tasks} for the active Bot. */
255
246
  loadTasks(): Promise<void>;
247
+ loadPackageUi(): Promise<void>;
248
+ callPackageUiTool(
249
+ contribution: PackageIframeContributionViewV1,
250
+ name: string,
251
+ input: unknown,
252
+ ): Promise<unknown>;
256
253
  /**
257
254
  * Cancels one subagent, explicitly and with the User's authentication. The
258
255
  * backend is the authority: the task this replaces in the list is the record
@@ -1,161 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- nextAssignmentPhase,
4
- requireStoredAssignmentSaga,
5
- settleAssignmentSaga,
6
- type AssignmentSagaEffects,
7
- type StoredAssignmentSaga,
8
- } from "./backend-assignment.js";
9
-
10
- function saga(
11
- phase: StoredAssignmentSaga["phase"],
12
- input: Partial<StoredAssignmentSaga> = {},
13
- ): StoredAssignmentSaga {
14
- return {
15
- schemaVersion: 1,
16
- commandId: "command-1",
17
- commandFingerprint: "configuration-command-v1:test",
18
- userId: "user-1",
19
- botId: "bot-1",
20
- operation: "replacing",
21
- assignmentId: "mail",
22
- generation: "generation-1",
23
- phase,
24
- target: {
25
- assignmentId: "mail",
26
- packageId: "mail",
27
- capabilityId: "send",
28
- connectionId: "new-connection",
29
- },
30
- previous: {
31
- assignmentId: "mail",
32
- packageId: "mail",
33
- capabilityId: "send",
34
- connectionId: "old-connection",
35
- state: "enabled",
36
- },
37
- previousGeneration: "old-generation",
38
- deadlineAt: Date.now() + 60_000,
39
- ...input,
40
- acceptedReceipt: input.acceptedReceipt ?? {
41
- schemaVersion: 1,
42
- commandId: "command-1",
43
- revision: 0,
44
- status: "pending",
45
- },
46
- };
47
- }
48
-
49
- function effects(log: string[], acknowledge = true): AssignmentSagaEffects {
50
- return {
51
- acknowledge: () => {
52
- log.push("acknowledge");
53
- return Promise.resolve(acknowledge);
54
- },
55
- compensate: () => {
56
- log.push("compensate");
57
- return Promise.resolve();
58
- },
59
- release: () => {
60
- log.push("release");
61
- return Promise.resolve(true);
62
- },
63
- rejectCommitted: () => {
64
- log.push("reject-committed");
65
- return Promise.resolve();
66
- },
67
- };
68
- }
69
-
70
- describe("Assignment saga transitions", () => {
71
- test("orders Replace as claim, commit, acknowledge, release", () => {
72
- const committed = nextAssignmentPhase(saga("claiming"), "claimed")!;
73
- const acknowledged = nextAssignmentPhase(committed, "committed")!;
74
- const releasing = nextAssignmentPhase(acknowledged, "acknowledged")!;
75
- expect([committed.phase, acknowledged.phase, releasing.phase]).toEqual([
76
- "committing",
77
- "acknowledging",
78
- "releasing",
79
- ]);
80
- expect(nextAssignmentPhase(releasing, "released")).toBeUndefined();
81
- });
82
-
83
- test("finishes a connection-free Assign after commit", () => {
84
- expect(
85
- nextAssignmentPhase(
86
- saga("committing", {
87
- operation: "assigning",
88
- target: {
89
- assignmentId: "clock",
90
- packageId: "clock",
91
- capabilityId: "time",
92
- },
93
- previous: undefined,
94
- previousGeneration: undefined,
95
- }),
96
- "committed",
97
- ),
98
- ).toBeUndefined();
99
- });
100
-
101
- test("strictly decodes durable saga state", () => {
102
- expect(requireStoredAssignmentSaga(saga("claiming"))).toMatchObject({
103
- operation: "replacing",
104
- phase: "claiming",
105
- });
106
- expect(() =>
107
- requireStoredAssignmentSaga({ ...saga("claiming"), extra: true }),
108
- ).toThrow("invalid fields");
109
- const hidden = saga("claiming") as StoredAssignmentSaga & { hidden?: true };
110
- Object.defineProperty(hidden, "hidden", { value: true });
111
- expect(() => requireStoredAssignmentSaga(hidden)).toThrow("invalid fields");
112
- expect(() =>
113
- requireStoredAssignmentSaga({
114
- ...saga("claiming"),
115
- [Symbol("extra")]: true,
116
- }),
117
- ).toThrow("invalid fields");
118
- expect(() =>
119
- requireStoredAssignmentSaga({
120
- ...saga("claiming"),
121
- acceptedReceipt: {
122
- schemaVersion: 1,
123
- commandId: "command-1",
124
- revision: 0,
125
- status: "applied",
126
- },
127
- }),
128
- ).toThrow("accepted receipt is invalid");
129
- });
130
-
131
- test("rejects out-of-order advancement", () => {
132
- expect(() => nextAssignmentPhase(saga("claiming"), "released")).toThrow(
133
- "cannot apply released while claiming",
134
- );
135
- });
136
-
137
- test("compensates a claiming saga and rejects an unacknowledged commit", async () => {
138
- const compensated: string[] = [];
139
- await expect(
140
- settleAssignmentSaga(saga("claiming"), effects(compensated)),
141
- ).resolves.toBe("compensated");
142
- expect(compensated).toEqual(["compensate"]);
143
-
144
- const acknowledged: string[] = [];
145
- await expect(
146
- settleAssignmentSaga(saga("acknowledging"), effects(acknowledged)),
147
- ).resolves.toBe("acknowledged");
148
- expect(acknowledged).toEqual(["acknowledge"]);
149
-
150
- const log: string[] = [];
151
- await expect(
152
- settleAssignmentSaga(saga("acknowledging"), effects(log, false)),
153
- ).resolves.toBe("rejected");
154
- expect(log).toEqual([
155
- "acknowledge",
156
- "compensate",
157
- "release",
158
- "reject-committed",
159
- ]);
160
- });
161
- });