@frockbot/plugin-shell 0.3.1 → 0.3.2

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 (33) hide show
  1. package/package.json +32 -29
  2. package/src/backend-applets.test.ts +581 -0
  3. package/src/backend-applets.ts +959 -0
  4. package/src/backend-authoring.test.ts +61 -19
  5. package/src/backend-authoring.ts +52 -26
  6. package/src/backend-composition.ts +64 -0
  7. package/src/backend-computer.test.ts +128 -0
  8. package/src/backend-computer.ts +81 -0
  9. package/src/backend-configuration.test.ts +8 -1
  10. package/src/backend-iframe-ui.test.ts +29 -12
  11. package/src/backend-isolate.ts +31 -5
  12. package/src/backend-package-catalog.test.ts +13 -8
  13. package/src/backend-package-catalog.ts +8 -6
  14. package/src/backend-recovery-integration.test.ts +23 -0
  15. package/src/backend.ts +508 -5
  16. package/src/client/AppletCanvas.vue +679 -0
  17. package/src/client/FrockBotApp.vue +169 -15
  18. package/src/client/PackageEntryTrigger.vue +77 -0
  19. package/src/client/PackageIframeHost.vue +148 -47
  20. package/src/client/PackageIframeSettings.vue +8 -6
  21. package/src/client/PackageSurfacePage.vue +39 -0
  22. package/src/client/applets-client.test.ts +204 -0
  23. package/src/client/applets-client.ts +139 -0
  24. package/src/client/applets-state.ts +64 -0
  25. package/src/client/index.test.ts +13 -7
  26. package/src/client/index.ts +308 -1
  27. package/src/client/package-iframe-entries.test.ts +122 -0
  28. package/src/client/package-iframe-entries.ts +112 -0
  29. package/src/client/package-iframe-host-message.test.ts +3 -3
  30. package/src/client/package-iframe-host-message.ts +3 -3
  31. package/src/client/styles.css +107 -1
  32. package/src/composition-views.ts +31 -6
  33. package/src/shared.ts +52 -0
package/src/backend.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type PersistSessionEvents,
22
22
  type SessionEvent,
23
23
  type WorkspacePathV1,
24
+ type WorkspaceRootV1,
24
25
  validateToolOccurrenceJournal,
25
26
  type BotCapabilitiesStub,
26
27
  type IsolateModelInvocationV1,
@@ -36,7 +37,11 @@ import {
36
37
  isClientIframeContribution,
37
38
  type FrockBotManifest,
38
39
  } from "@frockbot/kernel-composition";
40
+ import { canonicalJson, sha256 } from "@frockbot/kernel-composition/compiler";
39
41
  import type { Plugin } from "cordis";
42
+ import type { ComputerRegistry } from "@frockbot/computer-core";
43
+ import { appletsSourceRootV1 } from "@frockbot/plugin-applets/root";
44
+ import { syncWorkspaceRootNowV1 } from "@frockbot/plugin-computer/agent";
40
45
  import {
41
46
  ACTIVE_RUN_KEY,
42
47
  BotDurableAuthority,
@@ -119,9 +124,31 @@ import {
119
124
  import {
120
125
  bootstrapCompositionGeneration,
121
126
  createShellCompositionHost,
127
+ type ShellAppletMountOptions,
122
128
  type ShellIsolateMountOptions,
123
129
  type ShellMountedComposition,
124
130
  } from "./backend-composition.js";
131
+ import {
132
+ createAppletCapabilityHostV1,
133
+ createAppletInstanceBindingV1,
134
+ appletRpcSnapshotV1 as rpcJsonSnapshotV1,
135
+ resolveAppletCompositionV1,
136
+ type AppletCapabilityHostV1,
137
+ type AppletInstanceNamespaceV1,
138
+ type AppletUserDirectoryV1,
139
+ } from "./backend-applets.js";
140
+ import {
141
+ APPLET_FOCUSED_KEY,
142
+ decodeFocusedAppletV1,
143
+ type FocusedAppletV1,
144
+ } from "@frockbot/kernel-do";
145
+ import {
146
+ decodeAppletProvenanceV1,
147
+ decodeAppletSummaryV1,
148
+ decodeAppletToolDeclarationV1,
149
+ decodeIsolateAppletsRequestV1,
150
+ type IsolateAppletsOutcomeV1,
151
+ } from "@frockbot/kernel-contracts";
125
152
  import { compositionFailureTurnTextV1 } from "./backend-composition-input.js";
126
153
  import {
127
154
  activateCompositionV1,
@@ -139,7 +166,10 @@ import {
139
166
  createPackageCatalogHost,
140
167
  createR2BotPackageCatalogReader,
141
168
  } from "./backend-package-catalog.js";
142
- import { createBotComputerSyncHost } from "./backend-computer.js";
169
+ import {
170
+ createBotComputerSyncHost,
171
+ declaredPackageRootsV1,
172
+ } from "./backend-computer.js";
143
173
  import {
144
174
  decodeDirectoryViewV1,
145
175
  decodeFlockReceiptV1,
@@ -406,6 +436,7 @@ import {
406
436
  type BotUnreadReceiptV1,
407
437
  type BotUnreadViewV1,
408
438
  } from "./unread.js";
439
+ import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
409
440
 
410
441
  export const BOT_CONFIGURATION_KEY = "bot-configuration";
411
442
  const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
@@ -489,6 +520,13 @@ export interface BotStateEnv {
489
520
  BOT_PACKAGES?: BotIsolateLoader;
490
521
  /** Immutable, content-addressed Package artifacts, read hash-verified. */
491
522
  APPLICATION_ARTIFACTS?: R2Bucket;
523
+ /**
524
+ * One Applet Durable Object per Applet instance (ADR 0022). Optional so a
525
+ * host without Applets still compiles; a Composition generation carrying an
526
+ * Applet member then fails verification, exactly as an isolate member does
527
+ * without a loader.
528
+ */
529
+ APPLET_STATES?: AppletInstanceNamespaceV1;
492
530
  /**
493
531
  * The remote Package Catalog bucket. Read here only to index the Skills that
494
532
  * arrived with the User's installed entries, at the generation each install
@@ -560,6 +598,13 @@ export interface ShellBotBackendHost {
560
598
  * Durable Object has no honest way to dispatch one.
561
599
  */
562
600
  subagents?: SubagentDurableBindingV1;
601
+ /**
602
+ * Immutable Package artifacts this bundle already carries, by object key.
603
+ *
604
+ * The application supplies these; the shell only hands them to the artifact
605
+ * store as a second place to look. See `createR2PackageArtifactStore`.
606
+ */
607
+ bundledPackageArtifacts?: ReadonlyMap<string, string>;
563
608
  invalidateComputerProjectionFile?(
564
609
  userId: string,
565
610
  botId: string,
@@ -611,6 +656,7 @@ export class ShellBotBackendContribution {
611
656
  readonly ctx: DurableObjectState;
612
657
  readonly env: BotStateEnv;
613
658
  private readonly compileApplication: typeof compileFoundationApplication;
659
+ private readonly bundledPackageArtifacts?: ReadonlyMap<string, string>;
614
660
  private readonly lifecycleAdmission?: ShellBotBackendHost["assertLifecycleActive"];
615
661
  private readonly reconciliationActivities = new Map<
616
662
  string,
@@ -676,6 +722,7 @@ export class ShellBotBackendContribution {
676
722
  this.env = host.env;
677
723
  this.compileApplication =
678
724
  host.compileApplication ?? compileFoundationApplication;
725
+ this.bundledPackageArtifacts = host.bundledPackageArtifacts;
679
726
  this.lifecycleAdmission = host.assertLifecycleActive;
680
727
  this.outboundFetch = host.outboundFetch;
681
728
  this.invalidateComputerProjectionFile =
@@ -1059,6 +1106,12 @@ export class ShellBotBackendContribution {
1059
1106
  }
1060
1107
 
1061
1108
  async run(command: OwnedBotTurnCommand): Promise<ClientTurnV1> {
1109
+ // Before admission, so the pin this Turn takes already carries whatever the
1110
+ // User's Applet directory says now.
1111
+ await this.resolveAppletComposition(
1112
+ { userId: command.userId, botId: command.botId },
1113
+ command,
1114
+ );
1062
1115
  return projectClientTurnV1(await this.authority.run(command));
1063
1116
  }
1064
1117
 
@@ -1141,7 +1194,34 @@ export class ShellBotBackendContribution {
1141
1194
  const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
1142
1195
  authorshipManifestKey(member.manifestHash),
1143
1196
  );
1144
- return stored ? decodeFrockBotManifest(stored.manifest) : undefined;
1197
+ if (stored) return decodeFrockBotManifest(stored.manifest);
1198
+ return await this.readApplicationMemberManifest(member);
1199
+ }
1200
+
1201
+ /**
1202
+ * The manifest of a member the *application* declared, not the Bot.
1203
+ *
1204
+ * `authorship:manifest:<hash>` is written by the authoring path and by a
1205
+ * Catalog install, so it exists for every member a Bot or its User put into
1206
+ * the Composition. A first-party artifact-backed member (ADR 0022 decision
1207
+ * 8) came from neither: it is in the compiled application, whose manifests
1208
+ * are already in this bundle. The `manifestHash` is still what decides —
1209
+ * the plan's manifest is accepted only when it hashes to exactly what the
1210
+ * generation recorded — so this is a second *place* to look, never a second
1211
+ * answer.
1212
+ */
1213
+ private async readApplicationMemberManifest(
1214
+ member: CompositionMemberV1,
1215
+ ): Promise<FrockBotManifest | undefined> {
1216
+ if (!member.artifact) return undefined;
1217
+ const application = await this.compileApplication();
1218
+ const declared = application.packages.find(
1219
+ (candidate) => candidate.id === member.packageId,
1220
+ );
1221
+ if (!declared) return undefined;
1222
+ const hash = await sha256(canonicalJson(declared.manifest));
1223
+ if (hash !== member.manifestHash) return undefined;
1224
+ return declared.manifest;
1145
1225
  }
1146
1226
 
1147
1227
  private async requireCompositionMemberManifest(
@@ -1184,6 +1264,59 @@ export class ShellBotBackendContribution {
1184
1264
  * and its provenance says who put it there. The write goes through the same
1185
1265
  * `writeSkillDocumentV1` the Bot's own `skill_write` uses, quota included.
1186
1266
  */
1267
+ /**
1268
+ * One file written into one of the User's durable roots, as the User.
1269
+ *
1270
+ * This is the stand-in for the Computer's sync in an environment that has
1271
+ * no Computer: an end-to-end run lands the bytes `applet build` would have
1272
+ * written, at the path the sync would have mirrored them to, through the
1273
+ * same store and with the same generation record. The writer is the User —
1274
+ * the authority the sync's `unattributed` mirror is *narrower* than — so
1275
+ * nothing here is a write the User could not have made from their own
1276
+ * Computer. A root belonging to another User is refused by the store.
1277
+ */
1278
+ async writeUserWorkspaceFile(
1279
+ identity: BotIdentity,
1280
+ request: {
1281
+ root: WorkspaceRootV1;
1282
+ path: string;
1283
+ bytes: Uint8Array;
1284
+ mediaType?: string;
1285
+ },
1286
+ ): Promise<
1287
+ | { status: "written"; generationId: string }
1288
+ | { status: "refused"; reason: string }
1289
+ > {
1290
+ await this.validateIdentity(identity);
1291
+ const files = (this.env as { WORKSPACE_FILES?: WorkspaceFilesV1 })
1292
+ .WORKSPACE_FILES;
1293
+ if (!files) {
1294
+ return { status: "refused", reason: "this Bot has no Workspace store" };
1295
+ }
1296
+ if (request.root.userId !== identity.userId) {
1297
+ return { status: "refused", reason: "the root belongs to another User" };
1298
+ }
1299
+ const path = { root: request.root, path: request.path };
1300
+ const existing = await files.stat(path);
1301
+ const outcome = await files.write({
1302
+ path,
1303
+ bytes: request.bytes,
1304
+ writer: { kind: "user", userId: identity.userId },
1305
+ expectedGenerationId:
1306
+ existing.status === "ok"
1307
+ ? existing.entry.generation.generationId
1308
+ : null,
1309
+ ...(request.mediaType ? { mediaType: request.mediaType } : {}),
1310
+ });
1311
+ if (outcome.status === "ok") {
1312
+ return {
1313
+ status: "written",
1314
+ generationId: outcome.generation.generationId,
1315
+ };
1316
+ }
1317
+ return { status: "refused", reason: outcome.reason };
1318
+ }
1319
+
1187
1320
  async writeUserSkill(
1188
1321
  identity: BotIdentity,
1189
1322
  draft: { slug: string; name: string; description: string; body: string },
@@ -1406,6 +1539,21 @@ export class ShellBotBackendContribution {
1406
1539
  // The isolate bindings follow the generation actually being mounted, so a
1407
1540
  // fail-closed fallback loads the last known good's members, not the
1408
1541
  // pinned generation's.
1542
+ // Applet tools route to the Applet Durable Object, which forwards to the
1543
+ // facet. The instance binding is minted once per Turn; the facet stub
1544
+ // itself never leaves that object.
1545
+ const appletInstances = this.env.APPLET_STATES
1546
+ ? createAppletInstanceBindingV1(
1547
+ this.env.APPLET_STATES,
1548
+ input.identity.userId,
1549
+ )
1550
+ : undefined;
1551
+ const appletRouting: ShellAppletMountOptions | undefined = appletInstances
1552
+ ? {
1553
+ invokeTool: (request) =>
1554
+ appletInstances(request.appletId).invokeTool(request),
1555
+ }
1556
+ : undefined;
1409
1557
  const host: CompositionMountHost<ShellMountedComposition> = {
1410
1558
  mount: async (mounting, signal) => {
1411
1559
  const isolate = await this.isolateMountOptions(input.identity, {
@@ -1439,6 +1587,7 @@ export class ShellBotBackendContribution {
1439
1587
  effect,
1440
1588
  ),
1441
1589
  ...(isolate ? { isolate } : {}),
1590
+ ...(appletRouting ? { applets: appletRouting } : {}),
1442
1591
  }).mount(mounting, signal);
1443
1592
  mountedRoot = mounted.root;
1444
1593
  mountedGeneration = mounted.generation;
@@ -1506,10 +1655,14 @@ export class ShellBotBackendContribution {
1506
1655
  "Package UI command does not match the mounted Composition generation",
1507
1656
  );
1508
1657
  }
1658
+ // Artifact-backed, not "not first-party": what makes a Package's page
1659
+ // able to name one of its tools is that the Package is loaded from an
1660
+ // immutable artifact with a manifest, which is exactly what ADR 0022
1661
+ // decision 8 gives a first-party Package too.
1509
1662
  const member = activation.mounted.generation.members.find(
1510
1663
  (candidate) =>
1511
1664
  candidate.packageId === directTool.packageId &&
1512
- candidate.provenance.kind !== "first-party",
1665
+ candidate.artifact !== undefined,
1513
1666
  );
1514
1667
  if (!member)
1515
1668
  throw new Error("Package UI command names an unavailable Package");
@@ -1665,7 +1818,10 @@ export class ShellBotBackendContribution {
1665
1818
  runId: turn.runId,
1666
1819
  turnId: turn.runId,
1667
1820
  loader,
1668
- artifacts: createR2PackageArtifactStore(artifacts),
1821
+ artifacts: createR2PackageArtifactStore(
1822
+ artifacts,
1823
+ this.bundledPackageArtifacts,
1824
+ ),
1669
1825
  manifestFor: (member) => this.requireCompositionMemberManifest(member),
1670
1826
  capabilitiesFor: (member) =>
1671
1827
  mintCapabilities({
@@ -1688,6 +1844,7 @@ export class ShellBotBackendContribution {
1688
1844
  bindingDigest: await isolateBindingDigestV1({
1689
1845
  userId: identity.userId,
1690
1846
  botId: identity.botId,
1847
+ runId: turn.runId,
1691
1848
  connections: authority.connections,
1692
1849
  ...(authority.model ? { model: authority.model } : {}),
1693
1850
  compositionGenerationId: turn.generationId,
@@ -2048,6 +2205,322 @@ export class ShellBotBackendContribution {
2048
2205
  };
2049
2206
  }
2050
2207
 
2208
+ // --- Applets (ADR 0022) --------------------------------------------------
2209
+
2210
+ /**
2211
+ * `ctx.applets` for one Bot, or `undefined` when this host cannot reach
2212
+ * Applets at all — no instance namespace, no artifact bucket, or no
2213
+ * Workspace. An absent capability is an `unavailable` outcome at the isolate
2214
+ * boundary, never a thrown error inside Bot code.
2215
+ */
2216
+ private appletCapabilityHost(
2217
+ identity: BotIdentity,
2218
+ active?: NonNullable<ShellBotBackendContribution["activeTurn"]>,
2219
+ ): AppletCapabilityHostV1 | undefined {
2220
+ const namespace = this.env.APPLET_STATES;
2221
+ const artifacts = this.env.APPLICATION_ARTIFACTS;
2222
+ const workspace = this.env.WORKSPACE_FILES;
2223
+ if (!namespace || !artifacts || !workspace) return undefined;
2224
+ const bucket = artifacts;
2225
+ return createAppletCapabilityHostV1({
2226
+ userId: identity.userId,
2227
+ botId: identity.botId,
2228
+ storage: {
2229
+ get: (key) => this.ctx.storage.get(key),
2230
+ put: (entries) => this.ctx.storage.put(entries),
2231
+ },
2232
+ directory: this.appletUserDirectory(identity),
2233
+ instanceFor: createAppletInstanceBindingV1(namespace, identity.userId),
2234
+ artifacts: {
2235
+ putPackageArtifact: async (contentHash, module) => {
2236
+ await bucket.put(`packages/${contentHash}.mjs`, module, {
2237
+ httpMetadata: { contentType: "application/javascript" },
2238
+ });
2239
+ },
2240
+ putPackageUiArtifact: async (contentHash, html) => {
2241
+ await bucket.put(`packages/${contentHash}.html`, html, {
2242
+ httpMetadata: { contentType: "text/html; charset=utf-8" },
2243
+ });
2244
+ },
2245
+ },
2246
+ workspace,
2247
+ // A publish reads `dist/` from the store, and `applet build` wrote it on
2248
+ // the Computer moments earlier in this very Turn — before the Turn's own
2249
+ // `turn-end` push. So the one root is reconciled first, through the one
2250
+ // sanctioned extra caller of the Computer's sync. It wakes nothing new: a
2251
+ // User with no Computer assignment has no root to pull, and the Bot that
2252
+ // just built on its Computer has it open already.
2253
+ syncSourceRootNow: active
2254
+ ? async () => {
2255
+ const root = active.mounted.runtime.root as unknown as {
2256
+ computers?: ComputerRegistry;
2257
+ sessions: typeof active.mounted.runtime.root.sessions;
2258
+ };
2259
+ const computerIdentity = { userId: identity.userId };
2260
+ if (!root.computers?.assignment(computerIdentity)) return;
2261
+ const session = root.sessions.get(active.sessionId);
2262
+ const started = session?.events.findLast(
2263
+ (event) => event.type === "step/start",
2264
+ );
2265
+ const turn = started?.type === "step/start" ? started.turn : 0;
2266
+ const computer = await root.computers.open(
2267
+ computerIdentity,
2268
+ { botId: identity.botId },
2269
+ { signal: active.signal },
2270
+ );
2271
+ await syncWorkspaceRootNowV1({
2272
+ computer,
2273
+ sessions: root.sessions,
2274
+ sessionId: active.sessionId,
2275
+ turn,
2276
+ root: appletsSourceRootV1(identity.userId),
2277
+ signal: active.signal,
2278
+ });
2279
+ }
2280
+ : undefined,
2281
+ composition: {
2282
+ current: () => this.authority.composition.current(),
2283
+ lastKnownGood: () => this.authority.composition.lastKnownGood(),
2284
+ propose: (generation, options) =>
2285
+ this.authority.composition.propose(generation, options),
2286
+ },
2287
+ });
2288
+ }
2289
+
2290
+ /** The User Durable Object's Applet directory, decoded on arrival. */
2291
+ private appletUserDirectory(identity: BotIdentity): AppletUserDirectoryV1 {
2292
+ const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
2293
+ // SAFETY: this namespace is bound to UserConfiguration; generated Worker
2294
+ // types do not expose its Applet directory RPC surface.
2295
+ const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
2296
+ listApplets(input: unknown): Promise<unknown>;
2297
+ readAppletCompositionInput(input: unknown): Promise<unknown>;
2298
+ createApplet(input: unknown): Promise<unknown>;
2299
+ recordAppletGeneration(input: unknown): Promise<unknown>;
2300
+ deleteApplet(input: unknown): Promise<unknown>;
2301
+ };
2302
+ const userId = identity.userId;
2303
+ return {
2304
+ async list() {
2305
+ const answer = rpcJsonSnapshotV1(
2306
+ await rpc.listApplets({ schemaVersion: 1, userId }),
2307
+ ) as { revision?: unknown; applets?: unknown };
2308
+ return {
2309
+ revision: Number(answer.revision ?? 0),
2310
+ applets: Array.isArray(answer.applets)
2311
+ ? answer.applets.map((applet) => decodeAppletSummaryV1(applet))
2312
+ : [],
2313
+ };
2314
+ },
2315
+ async compositionInput() {
2316
+ const answer = rpcJsonSnapshotV1(
2317
+ await rpc.readAppletCompositionInput({ schemaVersion: 1, userId }),
2318
+ ) as { revision?: unknown; applets?: unknown };
2319
+ return {
2320
+ revision: Number(answer.revision ?? 0),
2321
+ applets: (Array.isArray(answer.applets) ? answer.applets : []).map(
2322
+ (applet) => {
2323
+ const entry = applet as Record<string, unknown>;
2324
+ return {
2325
+ appletId: String(entry.appletId),
2326
+ generationId: String(entry.generationId),
2327
+ tools: (Array.isArray(entry.tools) ? entry.tools : []).map(
2328
+ (tool, index) =>
2329
+ decodeAppletToolDeclarationV1(
2330
+ tool,
2331
+ `Applet tool declaration[${index}]`,
2332
+ ),
2333
+ ),
2334
+ provenance: decodeAppletProvenanceV1(entry.provenance),
2335
+ };
2336
+ },
2337
+ ),
2338
+ };
2339
+ },
2340
+ async create(input) {
2341
+ return decodeAppletSummaryV1(
2342
+ rpcJsonSnapshotV1(
2343
+ await rpc.createApplet({
2344
+ schemaVersion: 1,
2345
+ userId,
2346
+ displayName: input.displayName,
2347
+ provenance: input.provenance,
2348
+ }),
2349
+ ),
2350
+ );
2351
+ },
2352
+ async recordGeneration(input) {
2353
+ return decodeAppletSummaryV1(
2354
+ rpcJsonSnapshotV1(
2355
+ await rpc.recordAppletGeneration({
2356
+ schemaVersion: 1,
2357
+ userId,
2358
+ appletId: input.appletId,
2359
+ generationId: input.generationId,
2360
+ tools: input.tools,
2361
+ }),
2362
+ ),
2363
+ );
2364
+ },
2365
+ async delete(appletId) {
2366
+ return decodeAppletSummaryV1(
2367
+ rpcJsonSnapshotV1(
2368
+ await rpc.deleteApplet({ schemaVersion: 1, userId, appletId }),
2369
+ ),
2370
+ );
2371
+ },
2372
+ };
2373
+ }
2374
+
2375
+ /**
2376
+ * The Applet capability at the isolate boundary. One RPC with an operation,
2377
+ * because seven near-identical forwarders would say nothing seven times; the
2378
+ * shapes are decoded here and the outcomes are declared, never thrown.
2379
+ */
2380
+ async isolateApplets(
2381
+ input: IsolateCallScopeV1,
2382
+ ): Promise<IsolateAppletsOutcomeV1> {
2383
+ const active = this.activeIsolateTurn(input);
2384
+ if (!active) {
2385
+ return {
2386
+ status: "unavailable",
2387
+ reason: "the Package is not running in this Bot's active Composition",
2388
+ };
2389
+ }
2390
+ const identity = { userId: input.userId, botId: input.botId };
2391
+ const host = this.appletCapabilityHost(identity, active);
2392
+ if (!host) {
2393
+ return { status: "unavailable", reason: "Applets are unavailable" };
2394
+ }
2395
+ const request = decodeIsolateAppletsRequestV1(input.request);
2396
+ const scope = {
2397
+ sessionId: input.sessionId,
2398
+ runId: input.runId,
2399
+ turnId: input.turnId,
2400
+ effectId: `applet:${input.turnId}:${request.op}:${
2401
+ "appletId" in request ? request.appletId : "new"
2402
+ }`,
2403
+ };
2404
+ try {
2405
+ switch (request.op) {
2406
+ case "list":
2407
+ return { status: "available", value: await host.list() };
2408
+ case "create":
2409
+ return {
2410
+ status: "available",
2411
+ value: await host.create(
2412
+ { displayName: request.displayName },
2413
+ scope,
2414
+ ),
2415
+ };
2416
+ case "publish":
2417
+ return {
2418
+ status: "available",
2419
+ value: await host.publish({ appletId: request.appletId }, scope),
2420
+ };
2421
+ case "revert":
2422
+ return {
2423
+ status: "available",
2424
+ value: await host.revert(
2425
+ {
2426
+ appletId: request.appletId,
2427
+ generationId: request.generationId,
2428
+ },
2429
+ scope,
2430
+ ),
2431
+ };
2432
+ case "delete":
2433
+ return {
2434
+ status: "available",
2435
+ value: await host.delete({ appletId: request.appletId }),
2436
+ };
2437
+ case "focus":
2438
+ return {
2439
+ status: "available",
2440
+ value: await host.focus({ appletId: request.appletId }),
2441
+ };
2442
+ case "generations":
2443
+ return {
2444
+ status: "available",
2445
+ value: await host.generations({ appletId: request.appletId }),
2446
+ };
2447
+ }
2448
+ } catch (error) {
2449
+ return {
2450
+ status: "unavailable",
2451
+ reason:
2452
+ error instanceof Error ? error.message : "the Applet call failed",
2453
+ };
2454
+ }
2455
+ }
2456
+
2457
+ /** The Session's focused Applet, as the shell and its route read it. */
2458
+ async readFocusedApplet(identity: BotIdentity): Promise<FocusedAppletV1> {
2459
+ await this.validateIdentity(identity);
2460
+ const stored = await this.ctx.storage.get<unknown>(APPLET_FOCUSED_KEY);
2461
+ return stored === undefined
2462
+ ? {
2463
+ schemaVersion: 1,
2464
+ appletId: null,
2465
+ changedAt: new Date(0).toISOString(),
2466
+ }
2467
+ : decodeFocusedAppletV1(stored);
2468
+ }
2469
+
2470
+ async setFocusedApplet(
2471
+ identity: BotIdentity,
2472
+ appletId: string | null,
2473
+ ): Promise<FocusedAppletV1> {
2474
+ await this.validateIdentity(identity);
2475
+ const focused = decodeFocusedAppletV1({
2476
+ schemaVersion: 1,
2477
+ appletId,
2478
+ changedAt: new Date().toISOString(),
2479
+ });
2480
+ await this.ctx.storage.put({ [APPLET_FOCUSED_KEY]: focused });
2481
+ return focused;
2482
+ }
2483
+
2484
+ /**
2485
+ * Resolve the User's Applet directory into this Bot's next Composition
2486
+ * generation, before a Turn is admitted.
2487
+ *
2488
+ * Outside the admission transaction on purpose: the pin is taken in one
2489
+ * storage transaction, which cannot make a cross-object call. A publish or a
2490
+ * delete therefore activates at the *next* admitted Turn, and an in-flight
2491
+ * Turn keeps the set it pinned — which is exactly what ADR 0022 promises.
2492
+ * A directory that cannot be read leaves the Bot on the generation it has;
2493
+ * an Applet change is never a reason a Turn cannot start.
2494
+ */
2495
+ private async resolveAppletComposition(
2496
+ identity: BotIdentity,
2497
+ command: OwnedBotTurnCommand,
2498
+ ): Promise<void> {
2499
+ if (!this.env.APPLET_STATES) return;
2500
+ try {
2501
+ await resolveAppletCompositionV1({
2502
+ directory: this.appletUserDirectory(identity),
2503
+ composition: {
2504
+ current: () => this.authority.composition.current(),
2505
+ propose: (generation, options) =>
2506
+ this.authority.composition.propose(generation, options),
2507
+ },
2508
+ storage: {
2509
+ get: (key) => this.ctx.storage.get(key),
2510
+ put: (entries) => this.ctx.storage.put(entries),
2511
+ },
2512
+ origin: {
2513
+ kind: "bot-authored",
2514
+ runId: command.runId,
2515
+ sessionId: command.sessionId,
2516
+ turnId: command.runId,
2517
+ },
2518
+ });
2519
+ } catch {
2520
+ // Visible through the Applet's own failure records; never a wedged Turn.
2521
+ }
2522
+ }
2523
+
2051
2524
  async isolateWorkspaceRead(
2052
2525
  input: IsolateCallScopeV1,
2053
2526
  ): Promise<IsolateWorkspaceOutcomeV1> {
@@ -3634,6 +4107,13 @@ export class ShellBotBackendContribution {
3634
4107
  user,
3635
4108
  packages: packageDefinitions,
3636
4109
  });
4110
+ // The durable roots this User's enabled Packages declare, read from the
4111
+ // same installations and the same compiled manifests the Composition is
4112
+ // resolved from. Handed to the Computer sync below; nothing else reads it.
4113
+ const packageRoots = declaredPackageRootsV1({
4114
+ installations: user.packages,
4115
+ packages: application.packages,
4116
+ });
3637
4117
  const readSecret = (name: string) => {
3638
4118
  // SAFETY: Worker secrets are dynamic string bindings not enumerable in Env.
3639
4119
  const value = (this.env as unknown as Record<string, unknown>)[name];
@@ -3879,7 +4359,7 @@ export class ShellBotBackendContribution {
3879
4359
  // object storage with an unattributed writer.
3880
4360
  ...(turn
3881
4361
  ? {
3882
- computerSync: createBotComputerSyncHost(this.env),
4362
+ computerSync: createBotComputerSyncHost(this.env, packageRoots),
3883
4363
  // The same Turn, as the writer a durable Computer write records.
3884
4364
  computerWriter: {
3885
4365
  sessionId: turn.sessionId,
@@ -5499,3 +5979,26 @@ export function createShellBotBackendPlugin(
5499
5979
  ): Plugin {
5500
5980
  return () => lifecycle.mount(createShellBotBackendContribution(host));
5501
5981
  }
5982
+
5983
+ /**
5984
+ * What an application hands this Contribution: the conversation surface and the Bot's Composition, under the
5985
+ * Package's own key so one wide host object can satisfy every Package's slice
5986
+ * without their fields colliding.
5987
+ */
5988
+ export interface ShellBotApplicationHostV1 {
5989
+ shell: ShellBotBackendHost;
5990
+ }
5991
+
5992
+ /**
5993
+ * The manifest's `backend` entry, resolved by specifier. The
5994
+ * application looks this descriptor up in its Contribution table; it never
5995
+ * branches on which Package it belongs to.
5996
+ */
5997
+ export const backendContribution = defineBotBackendContribution<
5998
+ ShellBotApplicationHostV1,
5999
+ ShellBotBackendContribution
6000
+ >({
6001
+ specifier: "@frockbot/plugin-shell/backend",
6002
+ create: (host, lifecycle) =>
6003
+ createShellBotBackendPlugin(host.shell, lifecycle),
6004
+ });