@frockbot/plugin-shell 0.3.4 → 0.3.6

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.
package/frockbot.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 4,
3
3
  "id": "shell",
4
- "displayName": "FrockBot Shell",
4
+ "displayName": "FrockBot",
5
5
  "version": "0.0.1",
6
6
  "compatibility": {
7
7
  "frockbot": ">=0.0.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.4",
51
- "@frockbot/application-foundation": "0.3.4",
52
- "@frockbot/catalog-core": "0.3.4",
53
- "@frockbot/client-core": "0.3.4",
54
- "@frockbot/client-ui": "0.3.4",
55
- "@frockbot/computer-core": "0.3.4",
56
- "@frockbot/computer-host-protocol": "0.3.4",
57
- "@frockbot/configuration-core": "0.3.4",
58
- "@frockbot/connection-core": "0.3.4",
59
- "@frockbot/kernel-agent-loop": "0.3.4",
60
- "@frockbot/kernel-composition": "0.3.4",
61
- "@frockbot/kernel-contracts": "0.3.4",
62
- "@frockbot/kernel-do": "0.3.4",
63
- "@frockbot/machine-protocol": "0.3.4",
64
- "@frockbot/plugin-applets": "0.3.4",
65
- "@frockbot/plugin-authoring": "0.3.4",
66
- "@frockbot/plugin-bot-template": "0.3.4",
67
- "@frockbot/plugin-computer": "0.3.4",
68
- "@frockbot/plugin-flock": "0.3.4",
69
- "@frockbot/plugin-image": "0.3.4",
70
- "@frockbot/plugin-machine-messages": "0.3.4",
71
- "@frockbot/plugin-mcp": "0.3.4",
72
- "@frockbot/plugin-memory": "0.3.4",
73
- "@frockbot/plugin-package-catalog": "0.3.4",
74
- "@frockbot/plugin-routines": "0.3.4",
75
- "@frockbot/plugin-skills": "0.3.4",
76
- "@frockbot/plugin-subagents": "0.3.4",
77
- "@frockbot/plugin-user-machine": "0.3.4",
78
- "@frockbot/protocol": "0.3.4",
50
+ "@frockbot/agent-runtime": "0.3.6",
51
+ "@frockbot/application-foundation": "0.3.6",
52
+ "@frockbot/catalog-core": "0.3.6",
53
+ "@frockbot/client-core": "0.3.6",
54
+ "@frockbot/client-ui": "0.3.6",
55
+ "@frockbot/computer-core": "0.3.6",
56
+ "@frockbot/computer-host-protocol": "0.3.6",
57
+ "@frockbot/configuration-core": "0.3.6",
58
+ "@frockbot/connection-core": "0.3.6",
59
+ "@frockbot/kernel-agent-loop": "0.3.6",
60
+ "@frockbot/kernel-composition": "0.3.6",
61
+ "@frockbot/kernel-contracts": "0.3.6",
62
+ "@frockbot/kernel-do": "0.3.6",
63
+ "@frockbot/machine-protocol": "0.3.6",
64
+ "@frockbot/plugin-applets": "0.3.6",
65
+ "@frockbot/plugin-authoring": "0.3.6",
66
+ "@frockbot/plugin-bot-template": "0.3.6",
67
+ "@frockbot/plugin-computer": "0.3.6",
68
+ "@frockbot/plugin-flock": "0.3.6",
69
+ "@frockbot/plugin-image": "0.3.6",
70
+ "@frockbot/plugin-machine-messages": "0.3.6",
71
+ "@frockbot/plugin-mcp": "0.3.6",
72
+ "@frockbot/plugin-memory": "0.3.6",
73
+ "@frockbot/plugin-package-catalog": "0.3.6",
74
+ "@frockbot/plugin-routines": "0.3.6",
75
+ "@frockbot/plugin-skills": "0.3.6",
76
+ "@frockbot/plugin-subagents": "0.3.6",
77
+ "@frockbot/plugin-user-machine": "0.3.6",
78
+ "@frockbot/protocol": "0.3.6",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.4",
84
+ "@frockbot/plugin-tools": "0.3.6",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -18,6 +18,8 @@ import {
18
18
  authorshipUndoIntentKey,
19
19
  authorshipUndoOutcomeKey,
20
20
  AUTHORSHIP_FAILURE_PREFIX,
21
+ classifyAuthoringEffectV1,
22
+ type AuthoringEffectOutcomeV1,
21
23
  type AuthoredArtifactRecordV1,
22
24
  type AuthoredManifestRecordV1,
23
25
  type AuthoringFailureRecordV1,
@@ -234,9 +236,13 @@ describe("a Bot authoring a Package", () => {
234
236
  quota?: (request: unknown) => unknown;
235
237
  runId?: string;
236
238
  currentToolNames?: readonly string[];
239
+ bundleTimeoutMs?: number;
237
240
  }) {
238
241
  let ids = 0;
239
242
  return createPackageAuthoringHost({
243
+ ...(options.bundleTimeoutMs === undefined
244
+ ? {}
245
+ : { bundleTimeoutMs: options.bundleTimeoutMs }),
240
246
  storage,
241
247
  composition,
242
248
  bundler: options.bundler as never,
@@ -911,4 +917,70 @@ describe("a Bot authoring a Package", () => {
911
917
  }),
912
918
  );
913
919
  });
920
+
921
+ test("a bundler outage leaves the effect retryable, not poisoned", async () => {
922
+ // F11: the "could not be reached" refusal returned without writing any
923
+ // outcome, so `classifyAuthoringEffectV1` called that effectId `unknown`
924
+ // forever — "it will not be bundled again". Since the effectId is
925
+ // run + Package + source hash, retrying the identical source in the same
926
+ // run could never succeed.
927
+ const effectId = "author-0123456789abcdef";
928
+ const offline = {
929
+ calls: 0,
930
+ bundle() {
931
+ offline.calls += 1;
932
+ return Promise.reject(
933
+ new Error('Worker "frockbot-cloudflare-bundler" not found'),
934
+ );
935
+ },
936
+ };
937
+ const refusal = await host({ bundler: offline as never }).author(
938
+ requestFor(),
939
+ );
940
+ expect(refusal.status).toBe("refused");
941
+
942
+ const outcome = storage.values.get(
943
+ authorshipArtifactKey(effectId),
944
+ ) as AuthoringEffectOutcomeV1;
945
+ expect(outcome).toMatchObject({ status: "unreachable", effectId });
946
+ expect(
947
+ classifyAuthoringEffectV1({
948
+ intent: storage.values.get(authorshipIntentKey(effectId)) as never,
949
+ outcome,
950
+ }),
951
+ ).toEqual({ kind: "fresh" });
952
+
953
+ // And the identical source, in the same run, now goes through.
954
+ const recovered = await host({
955
+ bundler: countingBundler((id) => bundledResult(id)),
956
+ }).author(requestFor());
957
+ expect(recovered.status).toBe("authored");
958
+ });
959
+
960
+ test("a bundler that never answers is refused at the deadline", async () => {
961
+ // F10: `bundle()` was awaited with no deadline and no AbortSignal, and the
962
+ // bundler runs esbuild-wasm inline — a hang there hung this tool call and
963
+ // the Turn around it, indefinitely.
964
+ const hanging = {
965
+ calls: 0,
966
+ bundle() {
967
+ hanging.calls += 1;
968
+ return new Promise<never>(() => {});
969
+ },
970
+ };
971
+ const outcome = await host({
972
+ bundler: hanging as never,
973
+ bundleTimeoutMs: 10,
974
+ }).author(requestFor());
975
+
976
+ expect(outcome).toMatchObject({ status: "refused" });
977
+ if (outcome.status !== "refused") return;
978
+ expect(outcome.reason).toContain(
979
+ "the Package bundler could not be reached: the Package bundler did not answer within 10ms",
980
+ );
981
+ // Nothing ran, so the effect stays retryable rather than poisoned (F11).
982
+ expect(
983
+ storage.values.get(authorshipArtifactKey("author-0123456789abcdef")),
984
+ ).toMatchObject({ status: "unreachable" });
985
+ });
914
986
  });
@@ -143,12 +143,45 @@ export interface PackageAuthoringHostOptions {
143
143
  };
144
144
  now?(): Date;
145
145
  newId?(): string;
146
+ /** Overrides `PACKAGE_BUNDLE_TIMEOUT_MS_V1`; tests drive the deadline. */
147
+ bundleTimeoutMs?: number;
146
148
  }
147
149
 
148
150
  function errorMessage(error: unknown): string {
149
151
  return error instanceof Error ? error.message : String(error);
150
152
  }
151
153
 
154
+ /**
155
+ * How long the Package bundler has to answer. It runs esbuild-wasm inline, so
156
+ * a hang there is a hang of this tool call and of the Turn around it; the
157
+ * service binding itself imposes no deadline (finding F10). Generous, because
158
+ * a cold `createWorker` is slow, and far short of any Turn-level bound.
159
+ */
160
+ const PACKAGE_BUNDLE_TIMEOUT_MS_V1 = 60_000;
161
+
162
+ /**
163
+ * `work`, or a rejection at the deadline. The pending work is abandoned, not
164
+ * cancelled: the bundler is stateless and side-effect free (its caller owns
165
+ * every durable write), so a late answer changes nothing.
166
+ */
167
+ async function withDeadline<T>(
168
+ work: Promise<T>,
169
+ milliseconds: number,
170
+ message: string,
171
+ ): Promise<T> {
172
+ let timer: ReturnType<typeof setTimeout> | undefined;
173
+ try {
174
+ return await Promise.race([
175
+ work,
176
+ new Promise<never>((_resolve, reject) => {
177
+ timer = setTimeout(() => reject(new Error(message)), milliseconds);
178
+ }),
179
+ ]);
180
+ } finally {
181
+ if (timer !== undefined) clearTimeout(timer);
182
+ }
183
+ }
184
+
152
185
  /**
153
186
  * Builds the authoring seam one admitted Turn runs under. Constructed per
154
187
  * Turn because provenance names the run and turn that produced the artifact.
@@ -691,18 +724,40 @@ export function createPackageAuthoringHost(
691
724
  let bundled;
692
725
  try {
693
726
  // Never inside a storage transaction: bundler CPU is the bundler's.
727
+ // Bounded, because the bundler runs esbuild-wasm inline: one that
728
+ // hangs rather than rejects would hang this tool call and the Turn
729
+ // with it (F10). An expiry takes the same path as unreachability —
730
+ // nothing ran either way.
731
+ const deadline =
732
+ options.bundleTimeoutMs ?? PACKAGE_BUNDLE_TIMEOUT_MS_V1;
694
733
  bundled = decodePackageBundleResultV1(
695
- await options.bundler.bundle(bundleRequest),
734
+ await withDeadline(
735
+ options.bundler.bundle(bundleRequest),
736
+ deadline,
737
+ `the Package bundler did not answer within ${deadline}ms`,
738
+ ),
696
739
  );
697
740
  } catch (error) {
698
- return refused(
699
- await recordFailure({
741
+ const failure = await recordFailure({
742
+ effectId,
743
+ packageId,
744
+ phase: "bundle",
745
+ reason: `the Package bundler could not be reached: ${errorMessage(error)}`,
746
+ });
747
+ // The bundler never answered, so nothing about this effect is
748
+ // uncertain. Recording that explicitly is what lets an identical
749
+ // retry in the same run proceed instead of classifying `unknown`
750
+ // forever (F11).
751
+ await options.storage.put({
752
+ [authorshipArtifactKey(effectId)]: {
753
+ schemaVersion: 1,
754
+ status: "unreachable",
700
755
  effectId,
701
- packageId,
702
- phase: "bundle",
703
- reason: `the Package bundler could not be reached: ${errorMessage(error)}`,
704
- }),
705
- );
756
+ failureId: failure.failureId,
757
+ reason: failure.reason,
758
+ } satisfies AuthoringEffectOutcomeV1,
759
+ });
760
+ return refused(failure);
706
761
  }
707
762
  if (bundled.status === "failed") {
708
763
  const failure = await recordFailure({
@@ -744,15 +799,26 @@ export function createPackageAuthoringHost(
744
799
  );
745
800
  }));
746
801
  if (uiMismatch || (uiPages === undefined && returnedPages.length > 0)) {
747
- return refused(
748
- await recordFailure({
802
+ const failure = await recordFailure({
803
+ effectId,
804
+ packageId,
805
+ phase: "bundle",
806
+ reason:
807
+ "the Package bundler returned UI bytes that do not match the recorded manifest",
808
+ });
809
+ // The bundler did answer, and deterministically: the same source
810
+ // would mismatch again. That is a settled failure, not an unknown —
811
+ // it too used to leave the effect with no outcome at all (F11).
812
+ await options.storage.put({
813
+ [authorshipArtifactKey(effectId)]: {
814
+ schemaVersion: 1,
815
+ status: "failed",
749
816
  effectId,
750
- packageId,
751
- phase: "bundle",
752
- reason:
753
- "the Package bundler returned UI bytes that do not match the recorded manifest",
754
- }),
755
- );
817
+ failureId: failure.failureId,
818
+ reason: failure.reason,
819
+ } satisfies AuthoringEffectOutcomeV1,
820
+ });
821
+ return refused(failure);
756
822
  }
757
823
  // Content-addressed and immutable: writing the same hash twice is a
758
824
  // no-op, so the object write is safe to repeat and the record is not.
@@ -702,10 +702,12 @@ describe("generic per-Turn model resolution", () => {
702
702
  acceptedAt: "2026-09-02T00:01:00.000Z",
703
703
  text: "must fail",
704
704
  }),
705
- ).rejects.toThrow("not installed and enabled");
705
+ ).rejects.toThrow("Turn this model's plugin back on in Plugins");
706
706
  expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
707
707
  status: "failed",
708
- failure: expect.stringContaining("not installed and enabled"),
708
+ failure: expect.stringContaining(
709
+ "Turn this model's plugin back on in Plugins",
710
+ ),
709
711
  });
710
712
  }
711
713
 
@@ -722,10 +724,10 @@ describe("generic per-Turn model resolution", () => {
722
724
  acceptedAt: "2026-09-02T00:02:00.000Z",
723
725
  text: "must fail",
724
726
  }),
725
- ).rejects.toThrow("is revoked");
727
+ ).rejects.toThrow("needs reconnecting");
726
728
  expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
727
729
  status: "failed",
728
- failure: expect.stringContaining("is revoked"),
730
+ failure: expect.stringContaining("needs reconnecting"),
729
731
  });
730
732
  }
731
733
  });
@@ -147,7 +147,12 @@ async function bootstrap(): Promise<CompositionGenerationV1> {
147
147
  }
148
148
 
149
149
  async function fixture(
150
- options: { connected?: boolean; uiAvailable?: boolean } = {},
150
+ options: {
151
+ connected?: boolean;
152
+ uiAvailable?: boolean;
153
+ /** A deployment with no published Catalog pins no generation. */
154
+ pinned?: boolean;
155
+ } = {},
151
156
  ) {
152
157
  const log: string[] = [];
153
158
  const records = new Map<string, unknown>();
@@ -220,8 +225,9 @@ async function fixture(
220
225
  },
221
226
  ]
222
227
  : [],
223
- catalogGeneration: "catalog-1",
224
- catalogIndexHash: "d".repeat(64),
228
+ ...(options.pinned === false
229
+ ? {}
230
+ : { catalogGeneration: "catalog-1", catalogIndexHash: "d".repeat(64) }),
225
231
  };
226
232
  const commands: UserConfigurationCommandV1[] = [];
227
233
  const userAuthority = {
@@ -306,6 +312,20 @@ describe("Bot Package Catalog host", () => {
306
312
  });
307
313
  });
308
314
 
315
+ test("explains an unpinned Catalog in words a person can act on", async () => {
316
+ const { host } = await fixture({ pinned: false });
317
+ // F4: an unpinned User used to be told "this User has no pinned Package
318
+ // Catalog generation", which the Bot then relayed verbatim.
319
+ for (const call of [
320
+ () => host.search({ query: "shipping" }),
321
+ () => host.inspect({ catalogId: "parcel-tracking" }),
322
+ ]) {
323
+ await expect(call()).rejects.toThrow(
324
+ "No Package Catalog is published for this deployment, so there is nothing to search or install",
325
+ );
326
+ }
327
+ });
328
+
309
329
  test("persists the iframe manifest before the User effect, appends a summary generation, and replays", async () => {
310
330
  const test = await fixture();
311
331
  const request = {
@@ -145,7 +145,13 @@ function requiredCatalogPin(user: UserSettingsViewV1): {
145
145
  indexHash: string;
146
146
  } {
147
147
  if (!user.catalogGeneration || !user.catalogIndexHash) {
148
- throw new Error("this User has no pinned Package Catalog generation");
148
+ // A User is pinned on the first configuration read that finds a published
149
+ // Catalog, so an absent pin means the deployment has none to find. Say
150
+ // that, rather than an internal sentence about pinning the Bot then
151
+ // relays to the person who asked.
152
+ throw new Error(
153
+ "No Package Catalog is published for this deployment, so there is nothing to search or install",
154
+ );
149
155
  }
150
156
  return {
151
157
  generation: user.catalogGeneration,
@@ -626,7 +626,11 @@ describe("Bot recovery", () => {
626
626
  expect(await recoveredAgain.listNotifications()).toEqual(notifications);
627
627
  });
628
628
 
629
- test("preserves an unresolved request for an explicit decision", async () => {
629
+ // ADR 0028. This used to park the run and hand the person a Resolve button.
630
+ // `provider-1` has no retrieval — none of the providers this deployment
631
+ // ships does — so there was never an answer to resolve it with, and the Bot
632
+ // stayed wedged behind it.
633
+ test("settles an unresolved request no provider can be asked about", async () => {
630
634
  const storage = new MemoryStorage();
631
635
  const settings = initializeBotSettingsV1("primary");
632
636
  const events = [
@@ -690,19 +694,14 @@ describe("Bot recovery", () => {
690
694
  env: {} as never,
691
695
  });
692
696
 
693
- await expect(recovered.listRuns()).resolves.toEqual({
694
- schemaVersion: 1,
695
- runs: [
696
- expect.objectContaining({
697
- schemaVersion: 2,
698
- runId: "run-lost-marker",
699
- status: "reconciliation-required",
700
- recovery: expect.objectContaining({ action: "resume" }),
701
- }),
702
- ],
703
- page: { truncated: false },
704
- });
705
- expect(storage.values.get("active-run")).toBe("run-lost-marker");
697
+ await recovered.listRuns();
698
+
699
+ const settled = storage.values.get("run:run-lost-marker") as StoredRun;
700
+ expect(settled.status).toBe("failed");
701
+ expect(settled.failure).toContain("stopped partway");
702
+ // The Bot is released: nothing is holding the next Turn behind a decision
703
+ // nobody can make.
704
+ expect(storage.values.get("active-run")).toBeUndefined();
706
705
  expect(storage.alarmAt).toBeUndefined();
707
706
  });
708
707
 
@@ -1310,17 +1309,18 @@ describe("Bot recovery", () => {
1310
1309
  { schemaVersion: 1, runId: `bounded-fence-${index}` },
1311
1310
  );
1312
1311
  }
1313
- await expect(
1314
- contribution.fenceRunAdmission(
1315
- { userId: "user-1", botId: "primary" },
1316
- { schemaVersion: 1, runId: "fence-over-capacity" },
1317
- ),
1318
- ).rejects.toThrow("Run admission fence capacity reached");
1312
+ // The index is a bounded FIFO: the oldest fence ages out, and the fence
1313
+ // itself always succeeds. Refusing it instead meant a Bot that had refused
1314
+ // 256 sends answered every later fence 500, and left the client looping
1315
+ // "Turn admission lookup failed".
1316
+ await contribution.fenceRunAdmission(
1317
+ { userId: "user-1", botId: "primary" },
1318
+ { schemaVersion: 1, runId: "fence-over-capacity" },
1319
+ );
1319
1320
  const fences = await storage.get<string[]>("run-admission-fences");
1320
1321
  expect(fences).toHaveLength(256);
1321
- expect(fences).toContain("command-fenced");
1322
- expect(fences).toContain("bounded-fence-0");
1323
- expect(fences).not.toContain("fence-over-capacity");
1322
+ expect(fences).toContain("fence-over-capacity");
1323
+ expect(fences).not.toContain("command-fenced");
1324
1324
  });
1325
1325
 
1326
1326
  test("rechecks a fence committed during execution-context resolution", async () => {