@frockbot/plugin-shell 0.3.8 → 0.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,6 +30,7 @@
30
30
  "./frockbot.json": "./frockbot.json",
31
31
  "./host": "./src/host.ts",
32
32
  "./manifest": "./src/manifest.ts",
33
+ "./notification-id": "./src/notification-id.ts",
33
34
  "./package.json": "./package.json",
34
35
  "./run-cursor": "./src/run-cursor.ts",
35
36
  "./run-protocol": "./src/run-protocol.ts",
@@ -47,41 +48,41 @@
47
48
  },
48
49
  "dependencies": {
49
50
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.8",
51
- "@frockbot/application-foundation": "0.3.8",
52
- "@frockbot/catalog-core": "0.3.8",
53
- "@frockbot/client-core": "0.3.8",
54
- "@frockbot/client-ui": "0.3.8",
55
- "@frockbot/computer-core": "0.3.8",
56
- "@frockbot/computer-host-protocol": "0.3.8",
57
- "@frockbot/configuration-core": "0.3.8",
58
- "@frockbot/connection-core": "0.3.8",
59
- "@frockbot/kernel-agent-loop": "0.3.8",
60
- "@frockbot/kernel-composition": "0.3.8",
61
- "@frockbot/kernel-contracts": "0.3.8",
62
- "@frockbot/kernel-do": "0.3.8",
63
- "@frockbot/machine-protocol": "0.3.8",
64
- "@frockbot/plugin-applets": "0.3.8",
65
- "@frockbot/plugin-authoring": "0.3.8",
66
- "@frockbot/plugin-bot-template": "0.3.8",
67
- "@frockbot/plugin-computer": "0.3.8",
68
- "@frockbot/plugin-flock": "0.3.8",
69
- "@frockbot/plugin-image": "0.3.8",
70
- "@frockbot/plugin-machine-messages": "0.3.8",
71
- "@frockbot/plugin-mcp": "0.3.8",
72
- "@frockbot/plugin-memory": "0.3.8",
73
- "@frockbot/plugin-package-catalog": "0.3.8",
74
- "@frockbot/plugin-routines": "0.3.8",
75
- "@frockbot/plugin-skills": "0.3.8",
76
- "@frockbot/plugin-subagents": "0.3.8",
77
- "@frockbot/plugin-user-machine": "0.3.8",
78
- "@frockbot/protocol": "0.3.8",
51
+ "@frockbot/agent-runtime": "0.3.10",
52
+ "@frockbot/application-foundation": "0.3.10",
53
+ "@frockbot/catalog-core": "0.3.10",
54
+ "@frockbot/client-core": "0.3.10",
55
+ "@frockbot/client-ui": "0.3.10",
56
+ "@frockbot/computer-core": "0.3.10",
57
+ "@frockbot/computer-host-protocol": "0.3.10",
58
+ "@frockbot/configuration-core": "0.3.10",
59
+ "@frockbot/connection-core": "0.3.10",
60
+ "@frockbot/kernel-agent-loop": "0.3.10",
61
+ "@frockbot/kernel-composition": "0.3.10",
62
+ "@frockbot/kernel-contracts": "0.3.10",
63
+ "@frockbot/kernel-do": "0.3.10",
64
+ "@frockbot/machine-protocol": "0.3.10",
65
+ "@frockbot/plugin-applets": "0.3.10",
66
+ "@frockbot/plugin-authoring": "0.3.10",
67
+ "@frockbot/plugin-bot-template": "0.3.10",
68
+ "@frockbot/plugin-computer": "0.3.10",
69
+ "@frockbot/plugin-flock": "0.3.10",
70
+ "@frockbot/plugin-image": "0.3.10",
71
+ "@frockbot/plugin-machine-messages": "0.3.10",
72
+ "@frockbot/plugin-mcp": "0.3.10",
73
+ "@frockbot/plugin-memory": "0.3.10",
74
+ "@frockbot/plugin-package-catalog": "0.3.10",
75
+ "@frockbot/plugin-routines": "0.3.10",
76
+ "@frockbot/plugin-skills": "0.3.10",
77
+ "@frockbot/plugin-subagents": "0.3.10",
78
+ "@frockbot/plugin-user-machine": "0.3.10",
79
+ "@frockbot/protocol": "0.3.10",
79
80
  "cordis": "4.0.0-rc.8",
80
81
  "vue": "3.5.41"
81
82
  },
82
83
  "devDependencies": {
83
84
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.8",
85
+ "@frockbot/plugin-tools": "0.3.10",
85
86
  "@types/bun": "1.3.6",
86
87
  "@types/node": "26.2.0",
87
88
  "@vitejs/plugin-vue": "6.0.8",
@@ -600,8 +600,10 @@ describe("generic per-Turn model resolution", () => {
600
600
  });
601
601
  await expect(contribution.listNotifications()).resolves.toEqual([
602
602
  expect.objectContaining({
603
+ // Colons would fail the acknowledge decoder, so the mint replaces
604
+ // them; a notification nobody can acknowledge 400s forever.
603
605
  notificationId:
604
- "package-connection-unavailable:run-1:bot-authored:flock-ai-ambient",
606
+ "package-connection-unavailable-run-1-bot-authored-flock-ai-ambient",
605
607
  title: "Connection unavailable",
606
608
  }),
607
609
  ]);
@@ -320,7 +320,7 @@ export function createPackageCatalogHost(
320
320
  change.action === "remove"
321
321
  ? change.input.packageId
322
322
  : change.input.catalogId,
323
- ...(change.action === "remove"
323
+ ...(change.action === "remove" || change.input.contentHash === undefined
324
324
  ? {}
325
325
  : { contentHash: change.input.contentHash }),
326
326
  }),
@@ -520,9 +520,12 @@ export function createPackageCatalogHost(
520
520
  index,
521
521
  catalogId: provenance.catalogId,
522
522
  });
523
+ // A first-party member pins no bundle, so "the entry still offers
524
+ // what the target generation recorded" is the two hashes agreeing,
525
+ // both present or both absent.
523
526
  if (
524
- !targetEntry?.bundle ||
525
- targetEntry.bundle.contentHash !== provenance.contentHash
527
+ !targetEntry ||
528
+ targetEntry.bundle?.contentHash !== provenance.contentHash
526
529
  ) {
527
530
  return undoRefused(
528
531
  request,
@@ -538,7 +541,9 @@ export function createPackageCatalogHost(
538
541
  version: targetCatalogMember.version,
539
542
  catalogId: provenance.catalogId,
540
543
  catalogGeneration: provenance.catalogGeneration,
541
- contentHash: provenance.contentHash,
544
+ ...(provenance.contentHash === undefined
545
+ ? {}
546
+ : { contentHash: provenance.contentHash }),
542
547
  };
543
548
  }
544
549
  const receipt = await options.user.execute(command);
@@ -673,17 +678,22 @@ export function createPackageCatalogHost(
673
678
  const resolved = await pinnedEntry(request.change.input.catalogId);
674
679
  user = resolved.user;
675
680
  entry = resolved.entry;
676
- if (!entry.bundle) {
677
- throw new Error(
678
- `Catalog entry "${entry.catalogId}" does not carry installable code`,
679
- );
680
- }
681
- if (entry.bundle.contentHash !== request.change.input.contentHash) {
681
+ // A first-party entry names reviewed compiled-in code and publishes
682
+ // no bundle: there is no artifact to verify and no hash to pin. The
683
+ // Plugins page has always installed such an entry; refusing it here
684
+ // is what made `package_install` by chat disagree with the UI (F4).
685
+ if (entry.bundle) {
686
+ if (entry.bundle.contentHash !== request.change.input.contentHash) {
687
+ throw new Error(
688
+ `Catalog entry "${entry.catalogId}" has content hash "${entry.bundle.contentHash}", not "${request.change.input.contentHash}"`,
689
+ );
690
+ }
691
+ await verifyArtifact(entry);
692
+ } else if (request.change.input.contentHash !== undefined) {
682
693
  throw new Error(
683
- `Catalog entry "${entry.catalogId}" has content hash "${entry.bundle.contentHash}", not "${request.change.input.contentHash}"`,
694
+ `Catalog entry "${entry.catalogId}" does not carry a Package bundle`,
684
695
  );
685
696
  }
686
- await verifyArtifact(entry);
687
697
  const member = base.members.find(
688
698
  (candidate) => candidate.packageId === entry.packageId,
689
699
  );
@@ -716,7 +726,7 @@ export function createPackageCatalogHost(
716
726
  version: entry.version,
717
727
  catalogId: entry.catalogId,
718
728
  catalogGeneration: resolved.generation,
719
- contentHash: entry.bundle.contentHash,
729
+ ...(entry.bundle ? { contentHash: entry.bundle.contentHash } : {}),
720
730
  manifestHash: entry.manifestHash,
721
731
  summary:
722
732
  request.change.input.summary ??
@@ -744,16 +754,24 @@ export function createPackageCatalogHost(
744
754
  // Intent and manifest before the User settings effect. The manifest key
745
755
  // is the existing isolate loader seam used by authored Packages.
746
756
  if (!existingIntent) {
757
+ const bundle = entry.bundle;
747
758
  await options.storage.put({
748
759
  [packageCatalogChangeIntentKey(request.effectId)]: intent,
749
- [authorshipManifestKey(intent.manifestHash)]: {
750
- schemaVersion: 1,
751
- manifestHash: intent.manifestHash,
752
- packageId: intent.packageId,
753
- version: intent.version,
754
- manifest: entry.bundle!.manifest,
755
- createdAt: intent.recordedAt,
756
- } satisfies AuthoredManifestRecordV1,
760
+ // A bundle-less entry has no manifest document to store; the mount
761
+ // resolves it from the compiled-in application instead, still
762
+ // against the `manifestHash` the generation records.
763
+ ...(bundle
764
+ ? {
765
+ [authorshipManifestKey(intent.manifestHash)]: {
766
+ schemaVersion: 1,
767
+ manifestHash: intent.manifestHash,
768
+ packageId: intent.packageId,
769
+ version: intent.version,
770
+ manifest: bundle.manifest,
771
+ createdAt: intent.recordedAt,
772
+ } satisfies AuthoredManifestRecordV1,
773
+ }
774
+ : {}),
757
775
  });
758
776
  existingIntent = intent;
759
777
  }
@@ -776,7 +794,9 @@ export function createPackageCatalogHost(
776
794
  version: intent.version,
777
795
  catalogId: intent.catalogId,
778
796
  catalogGeneration: intent.catalogGeneration,
779
- contentHash: intent.contentHash,
797
+ ...(intent.contentHash === undefined
798
+ ? {}
799
+ : { contentHash: intent.contentHash }),
780
800
  };
781
801
  const receipt = await options.user.execute(command);
782
802
  if (receipt.status === "rejected") {
@@ -789,7 +809,7 @@ export function createPackageCatalogHost(
789
809
  (member) => member.packageId !== intent.packageId,
790
810
  );
791
811
  if (action !== "remove") {
792
- const bundle = entry.bundle!;
812
+ const bundle = entry.bundle;
793
813
  const member: CompositionMemberV1 = {
794
814
  packageId: entry.packageId,
795
815
  specifier: `catalog:${entry.catalogId}`,
@@ -801,14 +821,20 @@ export function createPackageCatalogHost(
801
821
  version: entry.version,
802
822
  catalogId: entry.catalogId,
803
823
  catalogGeneration: intent.catalogGeneration,
804
- contentHash: bundle.contentHash,
805
- },
806
- artifact: {
807
- contentHash: bundle.contentHash,
808
- size: bundle.size,
809
- mediaType: bundle.mediaType,
810
- bundlerVersion: bundle.bundlerVersion,
824
+ ...(bundle ? { contentHash: bundle.contentHash } : {}),
811
825
  },
826
+ // No artifact ⇒ the member mounts in the kernel isolate from the
827
+ // compiled-in Package, exactly as the UI install records it.
828
+ ...(bundle
829
+ ? {
830
+ artifact: {
831
+ contentHash: bundle.contentHash,
832
+ size: bundle.size,
833
+ mediaType: bundle.mediaType,
834
+ bundlerVersion: bundle.bundlerVersion,
835
+ },
836
+ }
837
+ : {}),
812
838
  };
813
839
  members.push(member);
814
840
  }
@@ -850,7 +876,9 @@ export function createPackageCatalogHost(
850
876
  packageId: intent.packageId,
851
877
  displayName: intent.displayName,
852
878
  ...(action === "remove" ? {} : { version: intent.version }),
853
- ...(action === "remove" ? {} : { contentHash: intent.contentHash }),
879
+ ...(action === "remove" || intent.contentHash === undefined
880
+ ? {}
881
+ : { contentHash: intent.contentHash }),
854
882
  generationId: generation.generationId,
855
883
  missingConnectionTypes: intent.missingConnectionTypes,
856
884
  recordedAt: intent.recordedAt,
@@ -301,6 +301,10 @@ export function routineInboxEntryViewV1(
301
301
  ...(entry.acknowledgedAt === undefined
302
302
  ? {}
303
303
  : { acknowledgedAt: entry.acknowledgedAt }),
304
+ ...(entry.repeatCount === undefined
305
+ ? {}
306
+ : { repeatCount: entry.repeatCount }),
307
+ ...(entry.failure === undefined ? {} : { failure: entry.failure }),
304
308
  };
305
309
  }
306
310
 
@@ -0,0 +1,222 @@
1
+ // The foreground `Task` wait (memory-v2 F4).
2
+ //
3
+ // A `Task {background:false}` that answered "dispatched" the instant anything
4
+ // went wrong read to the model as a call that had finished with no output. It
5
+ // then spent two more steps on `task_check` and `task_resume`. The wait must
6
+ // hold until the child settles, and give up only when the record is really
7
+ // unreadable.
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
10
+ import type {
11
+ TaskOutcomeV1,
12
+ TaskRecordV1,
13
+ } from "@frockbot/plugin-subagents/records";
14
+ import { taskKeyV1 } from "@frockbot/plugin-subagents/storage-keys";
15
+ import type { ShellBotBackendHost } from "./backend.js";
16
+
17
+ const identity = { userId: "user-1", botId: "primary" };
18
+ const TASK_ID = "task-1";
19
+
20
+ class MemoryStorage {
21
+ readonly values = new Map<string, unknown>();
22
+ /** Reads to fail before the first success, to stand in for contention. */
23
+ failReads = 0;
24
+ reads = 0;
25
+
26
+ get<T>(key: string): Promise<T | undefined> {
27
+ this.reads += 1;
28
+ if (this.failReads > 0) {
29
+ this.failReads -= 1;
30
+ return Promise.reject(new Error("storage is busy"));
31
+ }
32
+ return Promise.resolve(
33
+ structuredClone(this.values.get(key)) as T | undefined,
34
+ );
35
+ }
36
+
37
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
38
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
39
+ else {
40
+ for (const [entry, item] of Object.entries(key)) {
41
+ this.values.set(entry, structuredClone(item));
42
+ }
43
+ }
44
+ return Promise.resolve();
45
+ }
46
+
47
+ delete(key: string): Promise<boolean> {
48
+ return Promise.resolve(this.values.delete(key));
49
+ }
50
+
51
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
52
+ return Promise.resolve(
53
+ new Map(
54
+ [...this.values.entries()].filter(([key]) =>
55
+ key.startsWith(options.prefix ?? ""),
56
+ ) as Array<[string, T]>,
57
+ ),
58
+ );
59
+ }
60
+
61
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
62
+ return callback(this);
63
+ }
64
+
65
+ setAlarm(): Promise<void> {
66
+ return Promise.resolve();
67
+ }
68
+
69
+ deleteAlarm(): Promise<void> {
70
+ return Promise.resolve();
71
+ }
72
+ }
73
+
74
+ const user: UserSettingsViewV1 = {
75
+ schemaVersion: 1,
76
+ revision: 0,
77
+ profile: { name: "User" },
78
+ packages: [],
79
+ connections: [],
80
+ };
81
+
82
+ function host(storage: MemoryStorage): ShellBotBackendHost {
83
+ return {
84
+ state: { storage } as unknown as DurableObjectState,
85
+ env: {
86
+ USER_CONFIGURATIONS: {
87
+ idFromName: () => "user-id",
88
+ get: () => ({ readConfiguration: () => Promise.resolve(user) }),
89
+ },
90
+ } as unknown as ShellBotBackendHost["env"],
91
+ };
92
+ }
93
+
94
+ interface Waiting {
95
+ sleeps: number;
96
+ materializeSettings(
97
+ identity: { userId: string; botId: string },
98
+ profile: { name: string },
99
+ ): Promise<unknown>;
100
+ wait(taskId: string): Promise<TaskOutcomeV1 | undefined>;
101
+ }
102
+
103
+ /**
104
+ * Exposes the wait and removes its sleep, which is the whole point of it.
105
+ *
106
+ * The class is reached through a dynamic import rather than a top-level one:
107
+ * the Shell's backend and the foundation application import each other, and a
108
+ * static import from a test module reads the class while it is still
109
+ * initializing.
110
+ */
111
+ async function waiting(host: ShellBotBackendHost): Promise<Waiting> {
112
+ // The application first: it is the half of the cycle that has to finish
113
+ // initializing before the Shell's class is readable.
114
+ await import("@frockbot/application-foundation/contributions");
115
+ const { ShellBotBackendContribution } = await import("./backend.js");
116
+ return new (class extends ShellBotBackendContribution {
117
+ sleeps = 0;
118
+
119
+ protected override sleep(): Promise<void> {
120
+ this.sleeps += 1;
121
+ return Promise.resolve();
122
+ }
123
+
124
+ wait(taskId: string): Promise<TaskOutcomeV1 | undefined> {
125
+ return this.awaitBlockingTask(identity, taskId, taskId);
126
+ }
127
+ })(host) as unknown as Waiting;
128
+ }
129
+
130
+ function record(overrides: Partial<TaskRecordV1> = {}): TaskRecordV1 {
131
+ return {
132
+ schemaVersion: 1,
133
+ taskId: TASK_ID,
134
+ type: "executor",
135
+ description: "read it",
136
+ promptDigest: "sha256:digest",
137
+ model: {
138
+ binding: {
139
+ packageId: "models",
140
+ capabilityId: "llm",
141
+ connectionId: "connection-1",
142
+ provider: "foundation",
143
+ providerModelId: "foundation-model",
144
+ },
145
+ slug: "foundation-model",
146
+ },
147
+ compositionGenerationId: "generation-1",
148
+ background: false,
149
+ depth: 1,
150
+ status: "running",
151
+ dispatch: {
152
+ runId: "run-1",
153
+ turnId: "turn-1",
154
+ sessionId: "user-1:primary",
155
+ },
156
+ childSessionId: `task:${TASK_ID}`,
157
+ attachments: [],
158
+ createdAt: "2026-09-03T00:00:00.000Z",
159
+ deadlineAt: "2026-09-03T00:30:00.000Z",
160
+ ...overrides,
161
+ };
162
+ }
163
+
164
+ async function fixture(
165
+ stored: TaskRecordV1,
166
+ ): Promise<{ storage: MemoryStorage; contribution: Waiting }> {
167
+ const storage = new MemoryStorage();
168
+ const contribution = await waiting(host(storage));
169
+ await contribution.materializeSettings(identity, { name: "Primary" });
170
+ storage.values.set(taskKeyV1(stored.taskId), structuredClone(stored));
171
+ return { storage, contribution };
172
+ }
173
+
174
+ describe("the foreground Task wait", () => {
175
+ test("answers with the child's settled outcome", async () => {
176
+ const { contribution } = await fixture(
177
+ record({
178
+ status: "completed",
179
+ outcome: {
180
+ status: "completed",
181
+ settledAt: "2026-09-03T00:00:10.000Z",
182
+ summary: "The root has six entries.",
183
+ },
184
+ }),
185
+ );
186
+
187
+ expect(await contribution.wait(TASK_ID)).toMatchObject({
188
+ status: "completed",
189
+ summary: "The root has six entries.",
190
+ });
191
+ });
192
+
193
+ // The regression: one failed read used to end the wait, and the Task tool
194
+ // then told the model the child was still running — for a child that had
195
+ // already settled.
196
+ test("rides out a transient read failure instead of giving up", async () => {
197
+ const { storage, contribution } = await fixture(
198
+ record({
199
+ status: "completed",
200
+ outcome: {
201
+ status: "completed",
202
+ settledAt: "2026-09-03T00:00:10.000Z",
203
+ summary: "The root has six entries.",
204
+ },
205
+ }),
206
+ );
207
+ storage.failReads = 2;
208
+
209
+ expect(await contribution.wait(TASK_ID)).toMatchObject({
210
+ status: "completed",
211
+ summary: "The root has six entries.",
212
+ });
213
+ expect(contribution.sleeps).toBeGreaterThan(0);
214
+ });
215
+
216
+ test("gives up when the record stays unreadable", async () => {
217
+ const { storage, contribution } = await fixture(record());
218
+ storage.failReads = Number.MAX_SAFE_INTEGER;
219
+
220
+ expect(await contribution.wait(TASK_ID)).toBeUndefined();
221
+ });
222
+ });