@frockbot/plugin-shell 0.0.0 → 0.1.0

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,356 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
3
+ import {
4
+ initializeBotSettingsV1,
5
+ type UserSettingsViewV1,
6
+ } from "@frockbot/configuration-core";
7
+ import {
8
+ createShellBotBackendContribution,
9
+ type ShellBotBackendHost,
10
+ } from "./backend.js";
11
+ import {
12
+ botTurnCommandFingerprintV1,
13
+ type StoredRun,
14
+ } from "./backend-contracts.js";
15
+ import { planStoppedRunRecovery } from "./backend-recovery.js";
16
+
17
+ class MemoryStorage {
18
+ readonly values = new Map<string, unknown>();
19
+ alarmAt: number | undefined;
20
+
21
+ get<T>(key: string): Promise<T | undefined> {
22
+ return Promise.resolve(
23
+ structuredClone(this.values.get(key)) as T | undefined,
24
+ );
25
+ }
26
+
27
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
28
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
29
+ else {
30
+ for (const [entry, item] of Object.entries(key)) {
31
+ this.values.set(entry, structuredClone(item));
32
+ }
33
+ }
34
+ return Promise.resolve();
35
+ }
36
+
37
+ delete(key: string): Promise<boolean> {
38
+ return Promise.resolve(this.values.delete(key));
39
+ }
40
+
41
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
42
+ return Promise.resolve(
43
+ new Map(
44
+ [...this.values.entries()].filter(([key]) =>
45
+ key.startsWith(options.prefix ?? ""),
46
+ ) as Array<[string, T]>,
47
+ ),
48
+ );
49
+ }
50
+
51
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
52
+ return callback(this);
53
+ }
54
+
55
+ setAlarm(timestamp: number): Promise<void> {
56
+ this.alarmAt = timestamp;
57
+ return Promise.resolve();
58
+ }
59
+
60
+ deleteAlarm(): Promise<void> {
61
+ this.alarmAt = undefined;
62
+ return Promise.resolve();
63
+ }
64
+ }
65
+
66
+ const user: UserSettingsViewV1 = {
67
+ schemaVersion: 1,
68
+ revision: 0,
69
+ profile: { name: "User" },
70
+ packages: [],
71
+ connections: [],
72
+ };
73
+
74
+ function host(storage: MemoryStorage): ShellBotBackendHost {
75
+ return {
76
+ state: { storage } as unknown as DurableObjectState,
77
+ env: {
78
+ USER_CONFIGURATIONS: {
79
+ idFromName: () => "user-id",
80
+ get: () => ({ readConfiguration: () => Promise.resolve(user) }),
81
+ },
82
+ } as unknown as ShellBotBackendHost["env"],
83
+ };
84
+ }
85
+
86
+ const identity = { userId: "user-1", botId: "primary" };
87
+ const turn = {
88
+ ...identity,
89
+ runId: "run-1",
90
+ sessionId: "user-1:primary",
91
+ acceptedAt: "2026-08-30T00:00:00.000Z",
92
+ text: "hello",
93
+ };
94
+
95
+ function stopCommand(commandId = "stop-1", runId = turn.runId) {
96
+ return { schemaVersion: 1, action: "stop", commandId, runId };
97
+ }
98
+
99
+ const timestamp = "2026-08-30T00:00:01.000Z";
100
+
101
+ function modelIntentEvents(): SessionEvent[] {
102
+ return [
103
+ { type: "session/created", createdAt: timestamp },
104
+ { type: "turn/start", turn: 1 },
105
+ { type: "step/start", turn: 1, step: 1 },
106
+ {
107
+ type: "model/request",
108
+ turn: 1,
109
+ step: 1,
110
+ request: {
111
+ requestId: "request-1",
112
+ provider: "foundation",
113
+ model: "foundation-model",
114
+ system: "system",
115
+ messages: [{ role: "user", content: "hello" }],
116
+ tools: [],
117
+ },
118
+ },
119
+ ].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
120
+ }
121
+
122
+ function toolIntentEvents(): SessionEvent[] {
123
+ return [
124
+ { type: "session/created", createdAt: timestamp },
125
+ { type: "turn/start", turn: 1 },
126
+ { type: "step/start", turn: 1, step: 1 },
127
+ {
128
+ type: "model/request",
129
+ turn: 1,
130
+ step: 1,
131
+ request: {
132
+ requestId: "request-1",
133
+ provider: "foundation",
134
+ model: "foundation-model",
135
+ system: "system",
136
+ messages: [{ role: "user", content: "hello" }],
137
+ tools: [],
138
+ },
139
+ },
140
+ {
141
+ type: "assistant/message",
142
+ turn: 1,
143
+ step: 1,
144
+ requestId: "request-1",
145
+ text: "",
146
+ toolCalls: [{ id: "provider-call", name: "effect", input: {} }],
147
+ },
148
+ {
149
+ type: "tool/call",
150
+ turn: 1,
151
+ step: 1,
152
+ occurrenceId: "tool:1:1:0",
153
+ name: "effect",
154
+ input: {},
155
+ },
156
+ ].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
157
+ }
158
+
159
+ function storedRun(overrides: Partial<StoredRun> = {}): StoredRun {
160
+ return {
161
+ runId: turn.runId,
162
+ commandFingerprint: botTurnCommandFingerprintV1(turn),
163
+ sessionId: turn.sessionId,
164
+ acceptedAt: turn.acceptedAt,
165
+ input: turn.text,
166
+ events: [],
167
+ effectAdmissions: [],
168
+ status: "running",
169
+ phase: "executing",
170
+ compositionGenerationId: "generation-1",
171
+ configurationSnapshot: initializeBotSettingsV1(identity.botId),
172
+ previousEventCount: 0,
173
+ ...overrides,
174
+ } as StoredRun;
175
+ }
176
+
177
+ async function fixture(run: StoredRun = storedRun()): Promise<{
178
+ storage: MemoryStorage;
179
+ contribution: ReturnType<typeof createShellBotBackendContribution>;
180
+ }> {
181
+ const storage = new MemoryStorage();
182
+ const contribution = createShellBotBackendContribution(host(storage));
183
+ await contribution.materializeSettings(identity, { name: "Primary" });
184
+ storage.values.set(`run:${run.runId}`, structuredClone(run));
185
+ storage.values.set("active-run", run.runId);
186
+ return { storage, contribution };
187
+ }
188
+
189
+ function stopReceiptKeys(storage: MemoryStorage): string[] {
190
+ return [...storage.values.keys()].filter((key) =>
191
+ key.startsWith("stop-receipt:"),
192
+ );
193
+ }
194
+
195
+ describe("durable Stop", () => {
196
+ test("records durable intent and an idempotency receipt", async () => {
197
+ const { storage, contribution } = await fixture();
198
+
199
+ const receipt = await contribution.stopRun(identity, stopCommand());
200
+
201
+ expect(receipt).toMatchObject({
202
+ schemaVersion: 1,
203
+ status: "accepted",
204
+ commandId: "stop-1",
205
+ runId: turn.runId,
206
+ });
207
+ expect(receipt.run.stopRequestedAt).toBeString();
208
+ // Acknowledgement projects accepted durable state, never terminal
209
+ // cancellation, and the run stays active until its effects settle.
210
+ expect(receipt.run.status).not.toBe("cancelled");
211
+ expect(storage.values.get("active-run")).toBe(turn.runId);
212
+ expect(
213
+ (storage.values.get(`run:${turn.runId}`) as StoredRun).stopRequestedAt,
214
+ ).toBe(receipt.run.stopRequestedAt);
215
+ expect(stopReceiptKeys(storage)).toEqual(["stop-receipt:stop-1"]);
216
+ });
217
+
218
+ test("replays an identical command and rejects an identifier collision", async () => {
219
+ const { storage, contribution } = await fixture();
220
+
221
+ const first = await contribution.stopRun(identity, stopCommand());
222
+ const replay = await contribution.stopRun(identity, stopCommand());
223
+
224
+ expect(replay.run.stopRequestedAt).toBe(first.run.stopRequestedAt);
225
+ expect(stopReceiptKeys(storage)).toEqual(["stop-receipt:stop-1"]);
226
+
227
+ await expect(
228
+ contribution.stopRun(identity, stopCommand("stop-1", "run-other")),
229
+ ).rejects.toThrow(
230
+ 'Stop idempotency key "stop-1" was reused for a different command',
231
+ );
232
+ });
233
+
234
+ test("rejects unknown, mistyped, and already terminal Stop commands", async () => {
235
+ const { storage, contribution } = await fixture();
236
+
237
+ await expect(
238
+ contribution.stopRun(identity, { schemaVersion: 1, action: "stop" }),
239
+ ).rejects.toThrow();
240
+ await expect(
241
+ contribution.stopRun(identity, {
242
+ schemaVersion: 1,
243
+ action: "cancel",
244
+ commandId: "stop-2",
245
+ runId: turn.runId,
246
+ }),
247
+ ).rejects.toThrow();
248
+ await expect(
249
+ contribution.stopRun(identity, stopCommand("stop-3", "missing-run")),
250
+ ).rejects.toThrow('run "missing-run" was not admitted');
251
+
252
+ storage.values.set(
253
+ `run:${turn.runId}`,
254
+ storedRun({
255
+ events: [
256
+ {
257
+ type: "turn/end",
258
+ seq: 0,
259
+ timestamp,
260
+ turn: 1,
261
+ outcome: "completed",
262
+ },
263
+ ] as SessionEvent[],
264
+ }),
265
+ );
266
+ await expect(
267
+ contribution.stopRun(identity, stopCommand("stop-4")),
268
+ ).rejects.toThrow(`run "${turn.runId}" is already terminal`);
269
+
270
+ storage.values.set(
271
+ `run:${turn.runId}`,
272
+ storedRun({ status: "completed", responseText: "already answered" }),
273
+ );
274
+ await expect(
275
+ contribution.stopRun(identity, stopCommand("stop-5")),
276
+ ).rejects.toThrow(`run "${turn.runId}" is already terminal`);
277
+ });
278
+
279
+ test("refuses a Stop that does not match the Bot's durable identity", async () => {
280
+ const { contribution } = await fixture();
281
+
282
+ await expect(
283
+ contribution.stopRun(
284
+ { userId: "user-2", botId: "primary" },
285
+ stopCommand(),
286
+ ),
287
+ ).rejects.toThrow();
288
+ });
289
+ });
290
+
291
+ describe("stopped run recovery", () => {
292
+ test("cancels a stopped run whose model effect was never admitted", async () => {
293
+ const run = storedRun({
294
+ events: modelIntentEvents(),
295
+ stopRequestedAt: timestamp,
296
+ effectAdmissions: [
297
+ { kind: "model", effectId: "request-1", outcome: "fenced" },
298
+ ],
299
+ });
300
+
301
+ const plan = planStoppedRunRecovery(run, run.events);
302
+
303
+ expect(plan.kind).toBe("cancel");
304
+ if (plan.kind !== "cancel") throw new Error("expected cancellation");
305
+ expect(plan.events.map((event) => event.type)).toContain(
306
+ "model/effect-not-started",
307
+ );
308
+ // The journal records the Turn as interrupted; the run record becomes
309
+ // terminal `cancelled` when `cancelStoredRun` settles it.
310
+ expect(plan.events.at(-1)).toMatchObject({
311
+ type: "turn/end",
312
+ outcome: "interrupted",
313
+ });
314
+ });
315
+
316
+ test("cancels a stopped run whose tool effect was never admitted", async () => {
317
+ const run = storedRun({
318
+ events: toolIntentEvents(),
319
+ stopRequestedAt: timestamp,
320
+ effectAdmissions: [
321
+ { kind: "model", effectId: "request-1", outcome: "admitted" },
322
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "fenced" },
323
+ ],
324
+ });
325
+
326
+ const plan = planStoppedRunRecovery(run, run.events);
327
+
328
+ expect(plan.kind).toBe("cancel");
329
+ if (plan.kind !== "cancel") throw new Error("expected cancellation");
330
+ expect(
331
+ plan.events.find((event) => event.type === "tool/result"),
332
+ ).toMatchObject({ status: "interrupted", isError: true });
333
+ });
334
+
335
+ test("keeps an admitted but unsettled effect reconciling instead of cancelling", async () => {
336
+ const run = storedRun({
337
+ events: modelIntentEvents(),
338
+ stopRequestedAt: timestamp,
339
+ effectAdmissions: [
340
+ { kind: "model", effectId: "request-1", outcome: "admitted" },
341
+ ],
342
+ });
343
+
344
+ expect(planStoppedRunRecovery(run, run.events)).toEqual({
345
+ kind: "reconcile",
346
+ });
347
+ });
348
+
349
+ test("refuses to plan recovery for a run carrying no durable Stop intent", () => {
350
+ const run = storedRun({ events: modelIntentEvents() });
351
+
352
+ expect(() => planStoppedRunRecovery(run, run.events)).toThrow(
353
+ `run "${turn.runId}" has no durable stop intent`,
354
+ );
355
+ });
356
+ });