@frockbot/plugin-shell 0.0.0 → 0.1.1

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 (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,518 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ bootstrapGeneration,
4
+ compositionArtifactSetHashV1,
5
+ compositionGenerationIdV1,
6
+ decodeCompositionGenerationV1,
7
+ type CompositionGenerationV1,
8
+ type CompositionMemberV1,
9
+ } from "@frockbot/kernel-composition/generation";
10
+ import type { PackageBundleResultV1 } from "@frockbot/kernel-contracts";
11
+ import {
12
+ artifactKey,
13
+ authorshipArtifactKey,
14
+ authorshipFailureKey,
15
+ authorshipIntentKey,
16
+ AUTHORSHIP_FAILURE_PREFIX,
17
+ type AuthoredArtifactRecordV1,
18
+ type AuthoringFailureRecordV1,
19
+ type AuthorPackageRequestV1,
20
+ type AuthorshipIntentV1,
21
+ } from "@frockbot/plugin-authoring";
22
+ import {
23
+ createPackageAuthoringHost,
24
+ type AuthoringCompositionStore,
25
+ type AuthoringStorage,
26
+ } from "./backend-authoring.ts";
27
+
28
+ const SOURCE = `export const tools = [{ name: "hello", description: "", inputSchema: {} }];
29
+ export async function execute() { return "hi"; }
30
+ `;
31
+
32
+ function requestFor(overrides: Partial<AuthorPackageRequestV1> = {}) {
33
+ return {
34
+ input: {
35
+ packageId: "hello-world",
36
+ displayName: "Hello world",
37
+ tool: { name: "hello", description: "Says hi", inputSchema: {} },
38
+ source: SOURCE,
39
+ },
40
+ sourceHash: "a".repeat(64),
41
+ effectId: "author-0123456789abcdef",
42
+ sessionId: "user-1:bot-1",
43
+ position: { turn: 1, step: 1 },
44
+ ...overrides,
45
+ } satisfies AuthorPackageRequestV1;
46
+ }
47
+
48
+ function memoryStorage() {
49
+ const values = new Map<string, unknown>();
50
+ const storage: AuthoringStorage & { values: Map<string, unknown> } = {
51
+ values,
52
+ get: <T>(key: string) => Promise.resolve(values.get(key) as T | undefined),
53
+ put: (entries) => {
54
+ for (const [key, value] of Object.entries(entries)) {
55
+ values.set(key, structuredClone(value));
56
+ }
57
+ return Promise.resolve();
58
+ },
59
+ };
60
+ return storage;
61
+ }
62
+
63
+ async function memoryComposition() {
64
+ const bootstrap = decodeCompositionGenerationV1({
65
+ ...(await bootstrapGeneration(
66
+ [
67
+ {
68
+ packageId: "shell",
69
+ specifier: "@frockbot/plugin-shell",
70
+ version: "0.0.1",
71
+ manifest: { id: "shell", version: "0.0.1" },
72
+ },
73
+ ],
74
+ { createdAt: "2026-08-31T00:00:00.000Z" },
75
+ )),
76
+ status: "active",
77
+ });
78
+ const generations = new Map<string, CompositionGenerationV1>([
79
+ [bootstrap.generationId, bootstrap],
80
+ ]);
81
+ let current = bootstrap.generationId;
82
+ const store: AuthoringCompositionStore & {
83
+ generations: Map<string, CompositionGenerationV1>;
84
+ currentId(): string;
85
+ } = {
86
+ generations,
87
+ currentId: () => current,
88
+ current: () => Promise.resolve(generations.get(current)!),
89
+ read: (generationId) => Promise.resolve(generations.get(generationId)),
90
+ propose: (generation, options) => {
91
+ if (generations.has(generation.generationId)) {
92
+ throw new Error("composition generation already exists");
93
+ }
94
+ generations.set(generation.generationId, generation);
95
+ if (options?.pin) current = generation.generationId;
96
+ return Promise.resolve();
97
+ },
98
+ retainedCount: () => Promise.resolve(generations.size),
99
+ };
100
+ return store;
101
+ }
102
+
103
+ /** Pins a generation that adds one member to the current one. */
104
+ async function pinMember(
105
+ store: Awaited<ReturnType<typeof memoryComposition>>,
106
+ member: CompositionMemberV1,
107
+ ) {
108
+ const parent = store.generations.get(store.currentId())!;
109
+ const members = [...parent.members, member].sort((left, right) =>
110
+ left.packageId.localeCompare(right.packageId),
111
+ );
112
+ const artifactSetHash = await compositionArtifactSetHashV1(members);
113
+ const createdAt = "2026-08-31T00:30:00.000Z";
114
+ await store.propose(
115
+ decodeCompositionGenerationV1({
116
+ schemaVersion: 1,
117
+ generationId: compositionGenerationIdV1(createdAt, artifactSetHash),
118
+ artifactSetHash,
119
+ parentGenerationId: parent.generationId,
120
+ createdAt,
121
+ origin: { kind: "user-install", userId: "user-1" },
122
+ members,
123
+ status: "active",
124
+ }),
125
+ { pin: true },
126
+ );
127
+ }
128
+
129
+ function countingBundler(result: (effectId: string) => PackageBundleResultV1): {
130
+ bundle(request: { effectId: string }): Promise<PackageBundleResultV1>;
131
+ calls: number;
132
+ } {
133
+ const binding = {
134
+ calls: 0,
135
+ bundle(request: { effectId: string }) {
136
+ binding.calls += 1;
137
+ return Promise.resolve(result(request.effectId));
138
+ },
139
+ };
140
+ return binding;
141
+ }
142
+
143
+ function bundledResult(effectId: string, hash = "b".repeat(64)) {
144
+ return {
145
+ schemaVersion: 1,
146
+ effectId,
147
+ status: "bundled",
148
+ artifact: {
149
+ contentHash: hash,
150
+ size: 128,
151
+ mediaType: "application/javascript",
152
+ bundlerVersion: "test-bundler@0",
153
+ },
154
+ module: "export const tools = [];",
155
+ diagnostics: [],
156
+ } satisfies PackageBundleResultV1;
157
+ }
158
+
159
+ describe("a Bot authoring a Package", () => {
160
+ let storage: ReturnType<typeof memoryStorage>;
161
+ let composition: Awaited<ReturnType<typeof memoryComposition>>;
162
+ let written: string[];
163
+ let reservations: unknown[];
164
+
165
+ beforeEach(async () => {
166
+ storage = memoryStorage();
167
+ composition = await memoryComposition();
168
+ written = [];
169
+ reservations = [];
170
+ });
171
+
172
+ function host(options: {
173
+ bundler: ReturnType<typeof countingBundler>;
174
+ quota?: (request: unknown) => unknown;
175
+ runId?: string;
176
+ }) {
177
+ let ids = 0;
178
+ return createPackageAuthoringHost({
179
+ storage,
180
+ composition,
181
+ bundler: options.bundler as never,
182
+ artifacts: {
183
+ putPackageArtifact: (contentHash) => {
184
+ written.push(contentHash);
185
+ return Promise.resolve();
186
+ },
187
+ headPackageArtifact: () => Promise.resolve(undefined),
188
+ },
189
+ quota: {
190
+ reserve: (request) => {
191
+ reservations.push(request);
192
+ return Promise.resolve(
193
+ (options.quota?.(request) as never) ?? {
194
+ schemaVersion: 1,
195
+ status: "reserved",
196
+ effectId: request.effectId,
197
+ day: request.day,
198
+ used: 1,
199
+ limit: 100,
200
+ },
201
+ );
202
+ },
203
+ },
204
+ userId: "user-1",
205
+ botId: "bot-1",
206
+ runId: options.runId ?? "run-1",
207
+ turnId: options.runId ?? "run-1",
208
+ compatibilityDate: "2026-08-27",
209
+ now: () => new Date("2026-08-31T01:00:00.000Z"),
210
+ newId: () => `id-${(ids += 1)}`,
211
+ });
212
+ }
213
+
214
+ test("records intent before the bundler and an artifact with full provenance", async () => {
215
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
216
+ const outcome = await host({ bundler }).author(requestFor());
217
+
218
+ expect(outcome.status).toBe("authored");
219
+ const intent = storage.values.get(
220
+ authorshipIntentKey("author-0123456789abcdef"),
221
+ ) as AuthorshipIntentV1;
222
+ expect(intent).toMatchObject({
223
+ status: "recorded",
224
+ packageId: "hello-world",
225
+ version: "0.0.1",
226
+ runId: "run-1",
227
+ });
228
+ // The source itself is never durable Bot state; its hash is.
229
+ expect(Object.hasOwn(intent, "source")).toBe(false);
230
+
231
+ const artifact = storage.values.get(
232
+ artifactKey("b".repeat(64)),
233
+ ) as AuthoredArtifactRecordV1;
234
+ expect(artifact.provenance).toEqual({
235
+ kind: "bot",
236
+ packageId: "hello-world",
237
+ version: "0.0.1",
238
+ botId: "bot-1",
239
+ sessionId: "user-1:bot-1",
240
+ turnId: "run-1",
241
+ runId: "run-1",
242
+ authoredAt: "2026-08-31T01:00:00.000Z",
243
+ });
244
+ expect(artifact.r2Key).toBe(`packages/${"b".repeat(64)}.mjs`);
245
+ expect(written).toEqual(["b".repeat(64)]);
246
+ });
247
+
248
+ test("proposes a pending generation pinned for the next Turn", async () => {
249
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
250
+ const outcome = await host({ bundler }).author(requestFor());
251
+ if (outcome.status !== "authored") throw new Error(outcome.reason);
252
+
253
+ const generation = composition.generations.get(outcome.generationId)!;
254
+ expect(generation.status).toBe("pending");
255
+ expect(generation.origin).toEqual({
256
+ kind: "bot-authored",
257
+ runId: "run-1",
258
+ sessionId: "user-1:bot-1",
259
+ turnId: "run-1",
260
+ });
261
+ expect(generation.members.map((member) => member.packageId)).toEqual([
262
+ "hello-world",
263
+ "shell",
264
+ ]);
265
+ // The pointer advances so the *next* admitted Turn pins the proposal.
266
+ expect(composition.currentId()).toBe(outcome.generationId);
267
+ expect(generation.artifactSetHash).toBe(
268
+ await compositionArtifactSetHashV1(generation.members),
269
+ );
270
+ });
271
+
272
+ test("a replayed effect id does not bundle twice or record a second generation", async () => {
273
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
274
+ const first = await host({ bundler }).author(requestFor());
275
+ const second = await host({ bundler }).author(requestFor());
276
+
277
+ expect(bundler.calls).toBe(1);
278
+ expect(second).toEqual(first);
279
+ expect(composition.generations.size).toBe(2);
280
+ });
281
+
282
+ test("re-authoring appends a version and supersedes the previous member", async () => {
283
+ const bundler = countingBundler((effectId) =>
284
+ bundledResult(
285
+ effectId,
286
+ effectId.includes("second") ? "c".repeat(64) : "b".repeat(64),
287
+ ),
288
+ );
289
+ const first = await host({ bundler }).author(requestFor());
290
+ const second = await host({ bundler, runId: "run-2" }).author(
291
+ requestFor({ effectId: "author-second", sourceHash: "d".repeat(64) }),
292
+ );
293
+ if (first.status !== "authored" || second.status !== "authored") {
294
+ throw new Error("authoring was refused");
295
+ }
296
+
297
+ expect(second.version).toBe("0.0.2");
298
+ expect(second.supersededVersion).toBe("0.0.1");
299
+ expect(second.generationId).not.toBe(first.generationId);
300
+ // Neither the earlier generation nor the earlier artifact was mutated.
301
+ expect(composition.generations.get(first.generationId)?.members).toEqual(
302
+ composition.generations.get(first.generationId)!.members,
303
+ );
304
+ expect(
305
+ composition.generations
306
+ .get(first.generationId)!
307
+ .members.find((member) => member.packageId === "hello-world")?.version,
308
+ ).toBe("0.0.1");
309
+ expect(
310
+ composition.generations
311
+ .get(second.generationId)!
312
+ .members.find((member) => member.packageId === "hello-world")?.version,
313
+ ).toBe("0.0.2");
314
+ expect(storage.values.get(artifactKey("b".repeat(64)))).toBeDefined();
315
+ expect(storage.values.get(artifactKey("c".repeat(64)))).toBeDefined();
316
+ expect(
317
+ composition.generations.get(second.generationId)?.parentGenerationId,
318
+ ).toBe(first.generationId);
319
+ });
320
+
321
+ test("shadowing a first-party Package is refused before any durable effect", async () => {
322
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
323
+ const outcome = await host({ bundler }).author(
324
+ requestFor({
325
+ input: { ...requestFor().input, packageId: "shell" },
326
+ }),
327
+ );
328
+
329
+ expect(outcome.status).toBe("refused");
330
+ if (outcome.status !== "refused") throw new Error("unreachable");
331
+ expect(outcome.reason).toContain('"shell"');
332
+ expect(outcome.reason).toContain("first-party provenance");
333
+ const failure = storage.values.get(
334
+ authorshipFailureKey(outcome.failureId),
335
+ ) as AuthoringFailureRecordV1;
336
+ expect(failure).toMatchObject({
337
+ phase: "compose",
338
+ packageId: "shell",
339
+ botId: "bot-1",
340
+ });
341
+ // Nothing durable happened beyond the failure record.
342
+ expect(bundler.calls).toBe(0);
343
+ expect(written).toEqual([]);
344
+ expect(composition.generations.size).toBe(1);
345
+ expect(reservations).toEqual([]);
346
+ expect(
347
+ storage.values.get(authorshipIntentKey("author-0123456789abcdef")),
348
+ ).toBeUndefined();
349
+ });
350
+
351
+ test("shadowing a User-installed Package is refused too", async () => {
352
+ await pinMember(composition, {
353
+ packageId: "hello-world",
354
+ specifier: "@acme/hello-world",
355
+ version: "1.2.3",
356
+ manifestHash: "f".repeat(64),
357
+ provenance: {
358
+ kind: "user",
359
+ packageId: "hello-world",
360
+ version: "1.2.3",
361
+ userId: "user-1",
362
+ authoredAt: "2026-08-30T00:00:00.000Z",
363
+ },
364
+ artifact: {
365
+ contentHash: "a".repeat(64),
366
+ size: 64,
367
+ mediaType: "application/javascript",
368
+ bundlerVersion: "test-bundler@0",
369
+ },
370
+ });
371
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
372
+ const outcome = await host({ bundler }).author(requestFor());
373
+
374
+ expect(outcome.status).toBe("refused");
375
+ if (outcome.status !== "refused") throw new Error("unreachable");
376
+ expect(outcome.reason).toContain("user provenance");
377
+ expect(bundler.calls).toBe(0);
378
+ expect(composition.generations.size).toBe(2);
379
+ });
380
+
381
+ test("a quota breach is a visible durable failure, not a throw", async () => {
382
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
383
+ const outcome = await host({
384
+ bundler,
385
+ quota: (request) => ({
386
+ schemaVersion: 1,
387
+ status: "refused",
388
+ effectId: (request as { effectId: string }).effectId,
389
+ day: "2026-08-31",
390
+ limitName: "authored-per-day",
391
+ reason: "this User has authored 100 generations on 2026-08-31",
392
+ used: 100,
393
+ limit: 100,
394
+ }),
395
+ }).author(requestFor());
396
+
397
+ expect(outcome.status).toBe("refused");
398
+ if (outcome.status !== "refused") throw new Error("unreachable");
399
+ expect(outcome.reason).toContain("durable per-User quota");
400
+ const failure = storage.values.get(
401
+ authorshipFailureKey(outcome.failureId),
402
+ ) as AuthoringFailureRecordV1;
403
+ expect(failure).toMatchObject({ phase: "quota", botId: "bot-1" });
404
+ // Nothing durable happened beyond the failure record.
405
+ expect(bundler.calls).toBe(0);
406
+ expect(written).toEqual([]);
407
+ expect(composition.generations.size).toBe(1);
408
+ expect(
409
+ storage.values.get(authorshipIntentKey("author-0123456789abcdef")),
410
+ ).toBeUndefined();
411
+ });
412
+
413
+ test("a bundler refusal is recorded as the effect's outcome and replays", async () => {
414
+ const bundler = countingBundler((effectId) => ({
415
+ schemaVersion: 1,
416
+ effectId,
417
+ status: "failed",
418
+ failure: "bundle-failed",
419
+ diagnostics: ["package.ts:2:1: Unexpected end of file"],
420
+ }));
421
+ const first = await host({ bundler }).author(requestFor());
422
+ const second = await host({ bundler }).author(requestFor());
423
+
424
+ expect(first.status).toBe("refused");
425
+ expect(second).toEqual(first);
426
+ expect(bundler.calls).toBe(1);
427
+ expect(composition.generations.size).toBe(1);
428
+ });
429
+
430
+ test("an intent with no outcome is reported, never re-bundled", async () => {
431
+ // The eviction window: intent durable, bundler outcome lost.
432
+ await storage.put({
433
+ [authorshipIntentKey("author-0123456789abcdef")]: {
434
+ schemaVersion: 1,
435
+ effectId: "author-0123456789abcdef",
436
+ botId: "bot-1",
437
+ sessionId: "user-1:bot-1",
438
+ runId: "run-1",
439
+ turnId: "run-1",
440
+ packageId: "hello-world",
441
+ version: "0.0.1",
442
+ sourceHash: "a".repeat(64),
443
+ sourceBytes: SOURCE.length,
444
+ recordedAt: "2026-08-31T00:30:00.000Z",
445
+ status: "recorded",
446
+ } satisfies AuthorshipIntentV1,
447
+ });
448
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
449
+ const outcome = await host({ bundler }).author(requestFor());
450
+
451
+ expect(outcome.status).toBe("refused");
452
+ expect(bundler.calls).toBe(0);
453
+ const failures = [...storage.values.entries()].filter(([key]) =>
454
+ key.startsWith(AUTHORSHIP_FAILURE_PREFIX),
455
+ );
456
+ expect(failures).toHaveLength(1);
457
+ expect((failures[0]?.[1] as AuthoringFailureRecordV1).phase).toBe(
458
+ "recovery",
459
+ );
460
+ });
461
+
462
+ test("an effect whose artifact record vanished is unknown, not re-bundled", async () => {
463
+ await storage.put({
464
+ [authorshipIntentKey("author-0123456789abcdef")]: {
465
+ schemaVersion: 1,
466
+ effectId: "author-0123456789abcdef",
467
+ botId: "bot-1",
468
+ sessionId: "user-1:bot-1",
469
+ runId: "run-1",
470
+ turnId: "run-1",
471
+ packageId: "hello-world",
472
+ version: "0.0.1",
473
+ sourceHash: "a".repeat(64),
474
+ sourceBytes: SOURCE.length,
475
+ recordedAt: "2026-08-31T00:30:00.000Z",
476
+ status: "recorded",
477
+ } satisfies AuthorshipIntentV1,
478
+ [authorshipArtifactKey("author-0123456789abcdef")]: {
479
+ schemaVersion: 1,
480
+ status: "bundled",
481
+ effectId: "author-0123456789abcdef",
482
+ contentHash: "e".repeat(64),
483
+ version: "0.0.1",
484
+ },
485
+ });
486
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
487
+ const outcome = await host({ bundler }).author(requestFor());
488
+
489
+ expect(outcome.status).toBe("refused");
490
+ expect(bundler.calls).toBe(0);
491
+ });
492
+
493
+ test("a host with no bundler refuses visibly instead of silently succeeding", async () => {
494
+ const outcome = await createPackageAuthoringHost({
495
+ storage,
496
+ composition,
497
+ quota: {
498
+ reserve: (request) =>
499
+ Promise.resolve({
500
+ schemaVersion: 1,
501
+ status: "reserved",
502
+ effectId: request.effectId,
503
+ day: request.day,
504
+ used: 1,
505
+ limit: 100,
506
+ }),
507
+ },
508
+ userId: "user-1",
509
+ botId: "bot-1",
510
+ runId: "run-1",
511
+ turnId: "run-1",
512
+ compatibilityDate: "2026-08-27",
513
+ }).author(requestFor());
514
+
515
+ expect(outcome.status).toBe("refused");
516
+ expect(composition.generations.size).toBe(1);
517
+ });
518
+ });