@frockbot/plugin-shell 0.3.1 → 0.3.3

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 (44) hide show
  1. package/package.json +32 -29
  2. package/src/agent.test.ts +15 -3
  3. package/src/backend-applets.test.ts +581 -0
  4. package/src/backend-applets.ts +959 -0
  5. package/src/backend-authoring.test.ts +61 -19
  6. package/src/backend-authoring.ts +66 -27
  7. package/src/backend-completion.ts +4 -2
  8. package/src/backend-composition.ts +64 -0
  9. package/src/backend-computer.test.ts +128 -0
  10. package/src/backend-computer.ts +81 -0
  11. package/src/backend-configuration.test.ts +8 -1
  12. package/src/backend-iframe-ui.test.ts +29 -12
  13. package/src/backend-isolate.ts +31 -5
  14. package/src/backend-package-catalog.test.ts +13 -8
  15. package/src/backend-package-catalog.ts +8 -6
  16. package/src/backend-recovery-integration.test.ts +23 -0
  17. package/src/backend-recovery.ts +20 -12
  18. package/src/backend-runner-iframe.test.ts +10 -1
  19. package/src/backend-runner.ts +9 -2
  20. package/src/backend-stop.test.ts +6 -6
  21. package/src/backend-supersede.test.ts +377 -0
  22. package/src/backend.ts +567 -13
  23. package/src/client/AppletCanvas.vue +679 -0
  24. package/src/client/FrockBotApp.vue +195 -21
  25. package/src/client/PackageEntryTrigger.vue +77 -0
  26. package/src/client/PackageIframeHost.vue +148 -47
  27. package/src/client/PackageIframeSettings.vue +8 -6
  28. package/src/client/PackageSurfacePage.vue +39 -0
  29. package/src/client/applets-client.test.ts +204 -0
  30. package/src/client/applets-client.ts +139 -0
  31. package/src/client/applets-state.ts +64 -0
  32. package/src/client/index.test.ts +221 -7
  33. package/src/client/index.ts +398 -6
  34. package/src/client/package-iframe-entries.test.ts +122 -0
  35. package/src/client/package-iframe-entries.ts +112 -0
  36. package/src/client/package-iframe-host-message.test.ts +3 -3
  37. package/src/client/package-iframe-host-message.ts +3 -3
  38. package/src/client/styles.css +118 -1
  39. package/src/composition-views.ts +31 -6
  40. package/src/run-protocol.test.ts +92 -0
  41. package/src/run-protocol.ts +193 -17
  42. package/src/shared.ts +70 -0
  43. package/src/terminal-records.test.ts +52 -1
  44. package/src/terminal-records.ts +48 -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,
@@ -114,14 +119,36 @@ import {
114
119
  eventsForFailedRun,
115
120
  latestModelRequestJournalState,
116
121
  planBotRunRecovery,
117
- planStoppedRunRecovery,
122
+ planInterruptedRunRecoveryV1,
118
123
  } from "./backend-recovery.js";
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,
@@ -349,7 +379,10 @@ import {
349
379
  projectPackageIframeCompositionV1,
350
380
  } from "./composition-views.js";
351
381
  import { executeBotTurn, executeDirectToolTurn } from "./backend-runner.js";
352
- import { shellTerminalRecordsV1 } from "./terminal-records.js";
382
+ import {
383
+ shellTerminalRecordsV1,
384
+ supersededTurnRecordsV1,
385
+ } from "./terminal-records.js";
353
386
  import {
354
387
  CLIENT_RUN_LIST_MAX_BYTES,
355
388
  CLIENT_RUN_PAGE_LIMIT,
@@ -406,6 +439,7 @@ import {
406
439
  type BotUnreadReceiptV1,
407
440
  type BotUnreadViewV1,
408
441
  } from "./unread.js";
442
+ import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
409
443
 
410
444
  export const BOT_CONFIGURATION_KEY = "bot-configuration";
411
445
  const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
@@ -441,7 +475,10 @@ interface StoredStopReceipt {
441
475
 
442
476
  function isTerminalStoredRunStatus(status: StoredRunStatus): boolean {
443
477
  return (
444
- status === "completed" || status === "failed" || status === "cancelled"
478
+ status === "completed" ||
479
+ status === "failed" ||
480
+ status === "cancelled" ||
481
+ status === "superseded"
445
482
  );
446
483
  }
447
484
 
@@ -489,6 +526,13 @@ export interface BotStateEnv {
489
526
  BOT_PACKAGES?: BotIsolateLoader;
490
527
  /** Immutable, content-addressed Package artifacts, read hash-verified. */
491
528
  APPLICATION_ARTIFACTS?: R2Bucket;
529
+ /**
530
+ * One Applet Durable Object per Applet instance (ADR 0022). Optional so a
531
+ * host without Applets still compiles; a Composition generation carrying an
532
+ * Applet member then fails verification, exactly as an isolate member does
533
+ * without a loader.
534
+ */
535
+ APPLET_STATES?: AppletInstanceNamespaceV1;
492
536
  /**
493
537
  * The remote Package Catalog bucket. Read here only to index the Skills that
494
538
  * arrived with the User's installed entries, at the generation each install
@@ -560,6 +604,13 @@ export interface ShellBotBackendHost {
560
604
  * Durable Object has no honest way to dispatch one.
561
605
  */
562
606
  subagents?: SubagentDurableBindingV1;
607
+ /**
608
+ * Immutable Package artifacts this bundle already carries, by object key.
609
+ *
610
+ * The application supplies these; the shell only hands them to the artifact
611
+ * store as a second place to look. See `createR2PackageArtifactStore`.
612
+ */
613
+ bundledPackageArtifacts?: ReadonlyMap<string, string>;
563
614
  invalidateComputerProjectionFile?(
564
615
  userId: string,
565
616
  botId: string,
@@ -611,6 +662,7 @@ export class ShellBotBackendContribution {
611
662
  readonly ctx: DurableObjectState;
612
663
  readonly env: BotStateEnv;
613
664
  private readonly compileApplication: typeof compileFoundationApplication;
665
+ private readonly bundledPackageArtifacts?: ReadonlyMap<string, string>;
614
666
  private readonly lifecycleAdmission?: ShellBotBackendHost["assertLifecycleActive"];
615
667
  private readonly reconciliationActivities = new Map<
616
668
  string,
@@ -632,7 +684,8 @@ export class ShellBotBackendContribution {
632
684
  subagentRole?: string;
633
685
  mounted: ShellMountedComposition;
634
686
  signal: AbortSignal;
635
- cancel(): void;
687
+ /** `detail` is recorded on the Turn's `turn/end`, never interpreted. */
688
+ cancel(detail?: string): void;
636
689
  }
637
690
  | undefined;
638
691
  /**
@@ -676,6 +729,7 @@ export class ShellBotBackendContribution {
676
729
  this.env = host.env;
677
730
  this.compileApplication =
678
731
  host.compileApplication ?? compileFoundationApplication;
732
+ this.bundledPackageArtifacts = host.bundledPackageArtifacts;
679
733
  this.lifecycleAdmission = host.assertLifecycleActive;
680
734
  this.outboundFetch = host.outboundFetch;
681
735
  this.invalidateComputerProjectionFile =
@@ -715,6 +769,9 @@ export class ShellBotBackendContribution {
715
769
  notification: (snapshot, result) =>
716
770
  this.createNotification(snapshot, result),
717
771
  terminalRecords: (input) => this.terminalPackageRecords(input),
772
+ supersededRecords: (input) => this.supersededPackageRecords(input),
773
+ interruptTurn: (runId, reason) =>
774
+ this.interruptActiveTurn(runId, reason),
718
775
  scheduledDeadlines: (transaction) =>
719
776
  this.scheduledDeadlines(transaction),
720
777
  scheduledWorkInFlight: () =>
@@ -1059,6 +1116,12 @@ export class ShellBotBackendContribution {
1059
1116
  }
1060
1117
 
1061
1118
  async run(command: OwnedBotTurnCommand): Promise<ClientTurnV1> {
1119
+ // Before admission, so the pin this Turn takes already carries whatever the
1120
+ // User's Applet directory says now.
1121
+ await this.resolveAppletComposition(
1122
+ { userId: command.userId, botId: command.botId },
1123
+ command,
1124
+ );
1062
1125
  return projectClientTurnV1(await this.authority.run(command));
1063
1126
  }
1064
1127
 
@@ -1141,7 +1204,34 @@ export class ShellBotBackendContribution {
1141
1204
  const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
1142
1205
  authorshipManifestKey(member.manifestHash),
1143
1206
  );
1144
- return stored ? decodeFrockBotManifest(stored.manifest) : undefined;
1207
+ if (stored) return decodeFrockBotManifest(stored.manifest);
1208
+ return await this.readApplicationMemberManifest(member);
1209
+ }
1210
+
1211
+ /**
1212
+ * The manifest of a member the *application* declared, not the Bot.
1213
+ *
1214
+ * `authorship:manifest:<hash>` is written by the authoring path and by a
1215
+ * Catalog install, so it exists for every member a Bot or its User put into
1216
+ * the Composition. A first-party artifact-backed member (ADR 0022 decision
1217
+ * 8) came from neither: it is in the compiled application, whose manifests
1218
+ * are already in this bundle. The `manifestHash` is still what decides —
1219
+ * the plan's manifest is accepted only when it hashes to exactly what the
1220
+ * generation recorded — so this is a second *place* to look, never a second
1221
+ * answer.
1222
+ */
1223
+ private async readApplicationMemberManifest(
1224
+ member: CompositionMemberV1,
1225
+ ): Promise<FrockBotManifest | undefined> {
1226
+ if (!member.artifact) return undefined;
1227
+ const application = await this.compileApplication();
1228
+ const declared = application.packages.find(
1229
+ (candidate) => candidate.id === member.packageId,
1230
+ );
1231
+ if (!declared) return undefined;
1232
+ const hash = await sha256(canonicalJson(declared.manifest));
1233
+ if (hash !== member.manifestHash) return undefined;
1234
+ return declared.manifest;
1145
1235
  }
1146
1236
 
1147
1237
  private async requireCompositionMemberManifest(
@@ -1184,6 +1274,59 @@ export class ShellBotBackendContribution {
1184
1274
  * and its provenance says who put it there. The write goes through the same
1185
1275
  * `writeSkillDocumentV1` the Bot's own `skill_write` uses, quota included.
1186
1276
  */
1277
+ /**
1278
+ * One file written into one of the User's durable roots, as the User.
1279
+ *
1280
+ * This is the stand-in for the Computer's sync in an environment that has
1281
+ * no Computer: an end-to-end run lands the bytes `applet build` would have
1282
+ * written, at the path the sync would have mirrored them to, through the
1283
+ * same store and with the same generation record. The writer is the User —
1284
+ * the authority the sync's `unattributed` mirror is *narrower* than — so
1285
+ * nothing here is a write the User could not have made from their own
1286
+ * Computer. A root belonging to another User is refused by the store.
1287
+ */
1288
+ async writeUserWorkspaceFile(
1289
+ identity: BotIdentity,
1290
+ request: {
1291
+ root: WorkspaceRootV1;
1292
+ path: string;
1293
+ bytes: Uint8Array;
1294
+ mediaType?: string;
1295
+ },
1296
+ ): Promise<
1297
+ | { status: "written"; generationId: string }
1298
+ | { status: "refused"; reason: string }
1299
+ > {
1300
+ await this.validateIdentity(identity);
1301
+ const files = (this.env as { WORKSPACE_FILES?: WorkspaceFilesV1 })
1302
+ .WORKSPACE_FILES;
1303
+ if (!files) {
1304
+ return { status: "refused", reason: "this Bot has no Workspace store" };
1305
+ }
1306
+ if (request.root.userId !== identity.userId) {
1307
+ return { status: "refused", reason: "the root belongs to another User" };
1308
+ }
1309
+ const path = { root: request.root, path: request.path };
1310
+ const existing = await files.stat(path);
1311
+ const outcome = await files.write({
1312
+ path,
1313
+ bytes: request.bytes,
1314
+ writer: { kind: "user", userId: identity.userId },
1315
+ expectedGenerationId:
1316
+ existing.status === "ok"
1317
+ ? existing.entry.generation.generationId
1318
+ : null,
1319
+ ...(request.mediaType ? { mediaType: request.mediaType } : {}),
1320
+ });
1321
+ if (outcome.status === "ok") {
1322
+ return {
1323
+ status: "written",
1324
+ generationId: outcome.generation.generationId,
1325
+ };
1326
+ }
1327
+ return { status: "refused", reason: outcome.reason };
1328
+ }
1329
+
1187
1330
  async writeUserSkill(
1188
1331
  identity: BotIdentity,
1189
1332
  draft: { slug: string; name: string; description: string; body: string },
@@ -1406,6 +1549,21 @@ export class ShellBotBackendContribution {
1406
1549
  // The isolate bindings follow the generation actually being mounted, so a
1407
1550
  // fail-closed fallback loads the last known good's members, not the
1408
1551
  // pinned generation's.
1552
+ // Applet tools route to the Applet Durable Object, which forwards to the
1553
+ // facet. The instance binding is minted once per Turn; the facet stub
1554
+ // itself never leaves that object.
1555
+ const appletInstances = this.env.APPLET_STATES
1556
+ ? createAppletInstanceBindingV1(
1557
+ this.env.APPLET_STATES,
1558
+ input.identity.userId,
1559
+ )
1560
+ : undefined;
1561
+ const appletRouting: ShellAppletMountOptions | undefined = appletInstances
1562
+ ? {
1563
+ invokeTool: (request) =>
1564
+ appletInstances(request.appletId).invokeTool(request),
1565
+ }
1566
+ : undefined;
1409
1567
  const host: CompositionMountHost<ShellMountedComposition> = {
1410
1568
  mount: async (mounting, signal) => {
1411
1569
  const isolate = await this.isolateMountOptions(input.identity, {
@@ -1439,6 +1597,7 @@ export class ShellBotBackendContribution {
1439
1597
  effect,
1440
1598
  ),
1441
1599
  ...(isolate ? { isolate } : {}),
1600
+ ...(appletRouting ? { applets: appletRouting } : {}),
1442
1601
  }).mount(mounting, signal);
1443
1602
  mountedRoot = mounted.root;
1444
1603
  mountedGeneration = mounted.generation;
@@ -1490,9 +1649,9 @@ export class ShellBotBackendContribution {
1490
1649
  : {}),
1491
1650
  mounted: activation.mounted,
1492
1651
  signal: controller.signal,
1493
- cancel: () => {
1652
+ cancel: (detail?: string) => {
1494
1653
  controller.abort("user");
1495
- activation.mounted.runtime.agent.agent.cancel("user");
1654
+ activation.mounted.runtime.agent.agent.cancel("user", detail);
1496
1655
  },
1497
1656
  };
1498
1657
  this.activeTurn = active;
@@ -1506,10 +1665,14 @@ export class ShellBotBackendContribution {
1506
1665
  "Package UI command does not match the mounted Composition generation",
1507
1666
  );
1508
1667
  }
1668
+ // Artifact-backed, not "not first-party": what makes a Package's page
1669
+ // able to name one of its tools is that the Package is loaded from an
1670
+ // immutable artifact with a manifest, which is exactly what ADR 0022
1671
+ // decision 8 gives a first-party Package too.
1509
1672
  const member = activation.mounted.generation.members.find(
1510
1673
  (candidate) =>
1511
1674
  candidate.packageId === directTool.packageId &&
1512
- candidate.provenance.kind !== "first-party",
1675
+ candidate.artifact !== undefined,
1513
1676
  );
1514
1677
  if (!member)
1515
1678
  throw new Error("Package UI command names an unavailable Package");
@@ -1597,6 +1760,7 @@ export class ShellBotBackendContribution {
1597
1760
  private cancelActiveTurn(cancellation: {
1598
1761
  sessionId: string;
1599
1762
  runId: string;
1763
+ detail?: string;
1600
1764
  }): boolean {
1601
1765
  const active = this.activeTurn;
1602
1766
  if (
@@ -1606,10 +1770,24 @@ export class ShellBotBackendContribution {
1606
1770
  ) {
1607
1771
  return false;
1608
1772
  }
1609
- active.cancel();
1773
+ active.cancel(cancellation.detail);
1610
1774
  return true;
1611
1775
  }
1612
1776
 
1777
+ /**
1778
+ * The kernel's advisory interrupt, bound to this object's resident Agent.
1779
+ *
1780
+ * It runs only after the durable intent that justifies it is written, and it
1781
+ * changes nothing durable itself: a Turn whose Agent is no longer resident
1782
+ * is stopped by the effect fence on its next external effect instead, which
1783
+ * is the same outcome by a slower road.
1784
+ */
1785
+ private interruptActiveTurn(runId: string, reason: string): void {
1786
+ const active = this.activeTurn;
1787
+ if (!active || active.runId !== runId) return;
1788
+ active.cancel(reason);
1789
+ }
1790
+
1613
1791
  /** The visible half of failing closed, through the Bot's notifications. */
1614
1792
  private async recordCompositionFailureNotification(
1615
1793
  settings: BotSettingsViewV1,
@@ -1665,7 +1843,10 @@ export class ShellBotBackendContribution {
1665
1843
  runId: turn.runId,
1666
1844
  turnId: turn.runId,
1667
1845
  loader,
1668
- artifacts: createR2PackageArtifactStore(artifacts),
1846
+ artifacts: createR2PackageArtifactStore(
1847
+ artifacts,
1848
+ this.bundledPackageArtifacts,
1849
+ ),
1669
1850
  manifestFor: (member) => this.requireCompositionMemberManifest(member),
1670
1851
  capabilitiesFor: (member) =>
1671
1852
  mintCapabilities({
@@ -1688,6 +1869,7 @@ export class ShellBotBackendContribution {
1688
1869
  bindingDigest: await isolateBindingDigestV1({
1689
1870
  userId: identity.userId,
1690
1871
  botId: identity.botId,
1872
+ runId: turn.runId,
1691
1873
  connections: authority.connections,
1692
1874
  ...(authority.model ? { model: authority.model } : {}),
1693
1875
  compositionGenerationId: turn.generationId,
@@ -2048,6 +2230,322 @@ export class ShellBotBackendContribution {
2048
2230
  };
2049
2231
  }
2050
2232
 
2233
+ // --- Applets (ADR 0022) --------------------------------------------------
2234
+
2235
+ /**
2236
+ * `ctx.applets` for one Bot, or `undefined` when this host cannot reach
2237
+ * Applets at all — no instance namespace, no artifact bucket, or no
2238
+ * Workspace. An absent capability is an `unavailable` outcome at the isolate
2239
+ * boundary, never a thrown error inside Bot code.
2240
+ */
2241
+ private appletCapabilityHost(
2242
+ identity: BotIdentity,
2243
+ active?: NonNullable<ShellBotBackendContribution["activeTurn"]>,
2244
+ ): AppletCapabilityHostV1 | undefined {
2245
+ const namespace = this.env.APPLET_STATES;
2246
+ const artifacts = this.env.APPLICATION_ARTIFACTS;
2247
+ const workspace = this.env.WORKSPACE_FILES;
2248
+ if (!namespace || !artifacts || !workspace) return undefined;
2249
+ const bucket = artifacts;
2250
+ return createAppletCapabilityHostV1({
2251
+ userId: identity.userId,
2252
+ botId: identity.botId,
2253
+ storage: {
2254
+ get: (key) => this.ctx.storage.get(key),
2255
+ put: (entries) => this.ctx.storage.put(entries),
2256
+ },
2257
+ directory: this.appletUserDirectory(identity),
2258
+ instanceFor: createAppletInstanceBindingV1(namespace, identity.userId),
2259
+ artifacts: {
2260
+ putPackageArtifact: async (contentHash, module) => {
2261
+ await bucket.put(`packages/${contentHash}.mjs`, module, {
2262
+ httpMetadata: { contentType: "application/javascript" },
2263
+ });
2264
+ },
2265
+ putPackageUiArtifact: async (contentHash, html) => {
2266
+ await bucket.put(`packages/${contentHash}.html`, html, {
2267
+ httpMetadata: { contentType: "text/html; charset=utf-8" },
2268
+ });
2269
+ },
2270
+ },
2271
+ workspace,
2272
+ // A publish reads `dist/` from the store, and `applet build` wrote it on
2273
+ // the Computer moments earlier in this very Turn — before the Turn's own
2274
+ // `turn-end` push. So the one root is reconciled first, through the one
2275
+ // sanctioned extra caller of the Computer's sync. It wakes nothing new: a
2276
+ // User with no Computer assignment has no root to pull, and the Bot that
2277
+ // just built on its Computer has it open already.
2278
+ syncSourceRootNow: active
2279
+ ? async () => {
2280
+ const root = active.mounted.runtime.root as unknown as {
2281
+ computers?: ComputerRegistry;
2282
+ sessions: typeof active.mounted.runtime.root.sessions;
2283
+ };
2284
+ const computerIdentity = { userId: identity.userId };
2285
+ if (!root.computers?.assignment(computerIdentity)) return;
2286
+ const session = root.sessions.get(active.sessionId);
2287
+ const started = session?.events.findLast(
2288
+ (event) => event.type === "step/start",
2289
+ );
2290
+ const turn = started?.type === "step/start" ? started.turn : 0;
2291
+ const computer = await root.computers.open(
2292
+ computerIdentity,
2293
+ { botId: identity.botId },
2294
+ { signal: active.signal },
2295
+ );
2296
+ await syncWorkspaceRootNowV1({
2297
+ computer,
2298
+ sessions: root.sessions,
2299
+ sessionId: active.sessionId,
2300
+ turn,
2301
+ root: appletsSourceRootV1(identity.userId),
2302
+ signal: active.signal,
2303
+ });
2304
+ }
2305
+ : undefined,
2306
+ composition: {
2307
+ current: () => this.authority.composition.current(),
2308
+ lastKnownGood: () => this.authority.composition.lastKnownGood(),
2309
+ propose: (generation, options) =>
2310
+ this.authority.composition.propose(generation, options),
2311
+ },
2312
+ });
2313
+ }
2314
+
2315
+ /** The User Durable Object's Applet directory, decoded on arrival. */
2316
+ private appletUserDirectory(identity: BotIdentity): AppletUserDirectoryV1 {
2317
+ const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
2318
+ // SAFETY: this namespace is bound to UserConfiguration; generated Worker
2319
+ // types do not expose its Applet directory RPC surface.
2320
+ const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
2321
+ listApplets(input: unknown): Promise<unknown>;
2322
+ readAppletCompositionInput(input: unknown): Promise<unknown>;
2323
+ createApplet(input: unknown): Promise<unknown>;
2324
+ recordAppletGeneration(input: unknown): Promise<unknown>;
2325
+ deleteApplet(input: unknown): Promise<unknown>;
2326
+ };
2327
+ const userId = identity.userId;
2328
+ return {
2329
+ async list() {
2330
+ const answer = rpcJsonSnapshotV1(
2331
+ await rpc.listApplets({ schemaVersion: 1, userId }),
2332
+ ) as { revision?: unknown; applets?: unknown };
2333
+ return {
2334
+ revision: Number(answer.revision ?? 0),
2335
+ applets: Array.isArray(answer.applets)
2336
+ ? answer.applets.map((applet) => decodeAppletSummaryV1(applet))
2337
+ : [],
2338
+ };
2339
+ },
2340
+ async compositionInput() {
2341
+ const answer = rpcJsonSnapshotV1(
2342
+ await rpc.readAppletCompositionInput({ schemaVersion: 1, userId }),
2343
+ ) as { revision?: unknown; applets?: unknown };
2344
+ return {
2345
+ revision: Number(answer.revision ?? 0),
2346
+ applets: (Array.isArray(answer.applets) ? answer.applets : []).map(
2347
+ (applet) => {
2348
+ const entry = applet as Record<string, unknown>;
2349
+ return {
2350
+ appletId: String(entry.appletId),
2351
+ generationId: String(entry.generationId),
2352
+ tools: (Array.isArray(entry.tools) ? entry.tools : []).map(
2353
+ (tool, index) =>
2354
+ decodeAppletToolDeclarationV1(
2355
+ tool,
2356
+ `Applet tool declaration[${index}]`,
2357
+ ),
2358
+ ),
2359
+ provenance: decodeAppletProvenanceV1(entry.provenance),
2360
+ };
2361
+ },
2362
+ ),
2363
+ };
2364
+ },
2365
+ async create(input) {
2366
+ return decodeAppletSummaryV1(
2367
+ rpcJsonSnapshotV1(
2368
+ await rpc.createApplet({
2369
+ schemaVersion: 1,
2370
+ userId,
2371
+ displayName: input.displayName,
2372
+ provenance: input.provenance,
2373
+ }),
2374
+ ),
2375
+ );
2376
+ },
2377
+ async recordGeneration(input) {
2378
+ return decodeAppletSummaryV1(
2379
+ rpcJsonSnapshotV1(
2380
+ await rpc.recordAppletGeneration({
2381
+ schemaVersion: 1,
2382
+ userId,
2383
+ appletId: input.appletId,
2384
+ generationId: input.generationId,
2385
+ tools: input.tools,
2386
+ }),
2387
+ ),
2388
+ );
2389
+ },
2390
+ async delete(appletId) {
2391
+ return decodeAppletSummaryV1(
2392
+ rpcJsonSnapshotV1(
2393
+ await rpc.deleteApplet({ schemaVersion: 1, userId, appletId }),
2394
+ ),
2395
+ );
2396
+ },
2397
+ };
2398
+ }
2399
+
2400
+ /**
2401
+ * The Applet capability at the isolate boundary. One RPC with an operation,
2402
+ * because seven near-identical forwarders would say nothing seven times; the
2403
+ * shapes are decoded here and the outcomes are declared, never thrown.
2404
+ */
2405
+ async isolateApplets(
2406
+ input: IsolateCallScopeV1,
2407
+ ): Promise<IsolateAppletsOutcomeV1> {
2408
+ const active = this.activeIsolateTurn(input);
2409
+ if (!active) {
2410
+ return {
2411
+ status: "unavailable",
2412
+ reason: "the Package is not running in this Bot's active Composition",
2413
+ };
2414
+ }
2415
+ const identity = { userId: input.userId, botId: input.botId };
2416
+ const host = this.appletCapabilityHost(identity, active);
2417
+ if (!host) {
2418
+ return { status: "unavailable", reason: "Applets are unavailable" };
2419
+ }
2420
+ const request = decodeIsolateAppletsRequestV1(input.request);
2421
+ const scope = {
2422
+ sessionId: input.sessionId,
2423
+ runId: input.runId,
2424
+ turnId: input.turnId,
2425
+ effectId: `applet:${input.turnId}:${request.op}:${
2426
+ "appletId" in request ? request.appletId : "new"
2427
+ }`,
2428
+ };
2429
+ try {
2430
+ switch (request.op) {
2431
+ case "list":
2432
+ return { status: "available", value: await host.list() };
2433
+ case "create":
2434
+ return {
2435
+ status: "available",
2436
+ value: await host.create(
2437
+ { displayName: request.displayName },
2438
+ scope,
2439
+ ),
2440
+ };
2441
+ case "publish":
2442
+ return {
2443
+ status: "available",
2444
+ value: await host.publish({ appletId: request.appletId }, scope),
2445
+ };
2446
+ case "revert":
2447
+ return {
2448
+ status: "available",
2449
+ value: await host.revert(
2450
+ {
2451
+ appletId: request.appletId,
2452
+ generationId: request.generationId,
2453
+ },
2454
+ scope,
2455
+ ),
2456
+ };
2457
+ case "delete":
2458
+ return {
2459
+ status: "available",
2460
+ value: await host.delete({ appletId: request.appletId }),
2461
+ };
2462
+ case "focus":
2463
+ return {
2464
+ status: "available",
2465
+ value: await host.focus({ appletId: request.appletId }),
2466
+ };
2467
+ case "generations":
2468
+ return {
2469
+ status: "available",
2470
+ value: await host.generations({ appletId: request.appletId }),
2471
+ };
2472
+ }
2473
+ } catch (error) {
2474
+ return {
2475
+ status: "unavailable",
2476
+ reason:
2477
+ error instanceof Error ? error.message : "the Applet call failed",
2478
+ };
2479
+ }
2480
+ }
2481
+
2482
+ /** The Session's focused Applet, as the shell and its route read it. */
2483
+ async readFocusedApplet(identity: BotIdentity): Promise<FocusedAppletV1> {
2484
+ await this.validateIdentity(identity);
2485
+ const stored = await this.ctx.storage.get<unknown>(APPLET_FOCUSED_KEY);
2486
+ return stored === undefined
2487
+ ? {
2488
+ schemaVersion: 1,
2489
+ appletId: null,
2490
+ changedAt: new Date(0).toISOString(),
2491
+ }
2492
+ : decodeFocusedAppletV1(stored);
2493
+ }
2494
+
2495
+ async setFocusedApplet(
2496
+ identity: BotIdentity,
2497
+ appletId: string | null,
2498
+ ): Promise<FocusedAppletV1> {
2499
+ await this.validateIdentity(identity);
2500
+ const focused = decodeFocusedAppletV1({
2501
+ schemaVersion: 1,
2502
+ appletId,
2503
+ changedAt: new Date().toISOString(),
2504
+ });
2505
+ await this.ctx.storage.put({ [APPLET_FOCUSED_KEY]: focused });
2506
+ return focused;
2507
+ }
2508
+
2509
+ /**
2510
+ * Resolve the User's Applet directory into this Bot's next Composition
2511
+ * generation, before a Turn is admitted.
2512
+ *
2513
+ * Outside the admission transaction on purpose: the pin is taken in one
2514
+ * storage transaction, which cannot make a cross-object call. A publish or a
2515
+ * delete therefore activates at the *next* admitted Turn, and an in-flight
2516
+ * Turn keeps the set it pinned — which is exactly what ADR 0022 promises.
2517
+ * A directory that cannot be read leaves the Bot on the generation it has;
2518
+ * an Applet change is never a reason a Turn cannot start.
2519
+ */
2520
+ private async resolveAppletComposition(
2521
+ identity: BotIdentity,
2522
+ command: OwnedBotTurnCommand,
2523
+ ): Promise<void> {
2524
+ if (!this.env.APPLET_STATES) return;
2525
+ try {
2526
+ await resolveAppletCompositionV1({
2527
+ directory: this.appletUserDirectory(identity),
2528
+ composition: {
2529
+ current: () => this.authority.composition.current(),
2530
+ propose: (generation, options) =>
2531
+ this.authority.composition.propose(generation, options),
2532
+ },
2533
+ storage: {
2534
+ get: (key) => this.ctx.storage.get(key),
2535
+ put: (entries) => this.ctx.storage.put(entries),
2536
+ },
2537
+ origin: {
2538
+ kind: "bot-authored",
2539
+ runId: command.runId,
2540
+ sessionId: command.sessionId,
2541
+ turnId: command.runId,
2542
+ },
2543
+ });
2544
+ } catch {
2545
+ // Visible through the Applet's own failure records; never a wedged Turn.
2546
+ }
2547
+ }
2548
+
2051
2549
  async isolateWorkspaceRead(
2052
2550
  input: IsolateCallScopeV1,
2053
2551
  ): Promise<IsolateWorkspaceOutcomeV1> {
@@ -3634,6 +4132,13 @@ export class ShellBotBackendContribution {
3634
4132
  user,
3635
4133
  packages: packageDefinitions,
3636
4134
  });
4135
+ // The durable roots this User's enabled Packages declare, read from the
4136
+ // same installations and the same compiled manifests the Composition is
4137
+ // resolved from. Handed to the Computer sync below; nothing else reads it.
4138
+ const packageRoots = declaredPackageRootsV1({
4139
+ installations: user.packages,
4140
+ packages: application.packages,
4141
+ });
3637
4142
  const readSecret = (name: string) => {
3638
4143
  // SAFETY: Worker secrets are dynamic string bindings not enumerable in Env.
3639
4144
  const value = (this.env as unknown as Record<string, unknown>)[name];
@@ -3879,7 +4384,7 @@ export class ShellBotBackendContribution {
3879
4384
  // object storage with an unattributed writer.
3880
4385
  ...(turn
3881
4386
  ? {
3882
- computerSync: createBotComputerSyncHost(this.env),
4387
+ computerSync: createBotComputerSyncHost(this.env, packageRoots),
3883
4388
  // The same Turn, as the writer a durable Computer write records.
3884
4389
  computerWriter: {
3885
4390
  sessionId: turn.sessionId,
@@ -4816,6 +5321,27 @@ export class ShellBotBackendContribution {
4816
5321
  });
4817
5322
  }
4818
5323
 
5324
+ /**
5325
+ * What a superseded Turn leaves for the Turn that replaced it.
5326
+ *
5327
+ * One durable input, drained once by the next conversational Turn. The
5328
+ * session log already carries what the Turn sent and what its tools
5329
+ * returned; this is the part that is not in the log — that it was cut off,
5330
+ * that nothing in flight completed, and that a subagent it dispatched is
5331
+ * still working. "A firing's outcome is delivered to the Bot's next
5332
+ * conversational Turn as durable input" and a superseded Turn's is too.
5333
+ */
5334
+ private supersededPackageRecords(input: {
5335
+ run: StoredRun;
5336
+ read<T>(key: string): Promise<T | undefined>;
5337
+ }): Promise<Record<string, unknown>> {
5338
+ return supersededTurnRecordsV1({
5339
+ run: input.run,
5340
+ now: new Date().toISOString(),
5341
+ read: input.read,
5342
+ });
5343
+ }
5344
+
4819
5345
  async alarm(): Promise<void> {
4820
5346
  // One alarm: the kernel defers while work is in flight, settles Package
4821
5347
  // scheduled work, and recovers the active run. A run left
@@ -5473,7 +5999,12 @@ export class ShellBotBackendContribution {
5473
5999
  `effect admission "${effect.effectId}" does not match durable intent`,
5474
6000
  );
5475
6001
  }
5476
- const outcome = run.stopRequestedAt ? "fenced" : "admitted";
6002
+ // Supersede fences exactly as Stop does. It is what makes an interrupt
6003
+ // durable rather than advisory: a Turn whose Agent never got the signal
6004
+ // — because the object was evicted and resumed — still starts no new
6005
+ // provider call or tool effect once the intent is recorded.
6006
+ const outcome =
6007
+ run.stopRequestedAt || run.supersededAt ? "fenced" : "admitted";
5477
6008
  const next = requireStoredRunV1({
5478
6009
  ...run,
5479
6010
  effectAdmissions: [
@@ -5499,3 +6030,26 @@ export function createShellBotBackendPlugin(
5499
6030
  ): Plugin {
5500
6031
  return () => lifecycle.mount(createShellBotBackendContribution(host));
5501
6032
  }
6033
+
6034
+ /**
6035
+ * What an application hands this Contribution: the conversation surface and the Bot's Composition, under the
6036
+ * Package's own key so one wide host object can satisfy every Package's slice
6037
+ * without their fields colliding.
6038
+ */
6039
+ export interface ShellBotApplicationHostV1 {
6040
+ shell: ShellBotBackendHost;
6041
+ }
6042
+
6043
+ /**
6044
+ * The manifest's `backend` entry, resolved by specifier. The
6045
+ * application looks this descriptor up in its Contribution table; it never
6046
+ * branches on which Package it belongs to.
6047
+ */
6048
+ export const backendContribution = defineBotBackendContribution<
6049
+ ShellBotApplicationHostV1,
6050
+ ShellBotBackendContribution
6051
+ >({
6052
+ specifier: "@frockbot/plugin-shell/backend",
6053
+ create: (host, lifecycle) =>
6054
+ createShellBotBackendPlugin(host.shell, lifecycle),
6055
+ });