@frockbot/plugin-computer 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 (42) hide show
  1. package/frockbot.json +25 -0
  2. package/package.json +54 -6
  3. package/src/agent.test.ts +271 -0
  4. package/src/agent.ts +1419 -0
  5. package/src/backend.test.ts +149 -0
  6. package/src/backend.ts +163 -0
  7. package/src/bot.test.ts +411 -0
  8. package/src/bot.ts +831 -0
  9. package/src/client/ComputerCard.test.ts +96 -0
  10. package/src/client/ComputerCard.vue +60 -0
  11. package/src/client/ComputerStrip.test.ts +54 -0
  12. package/src/client/ComputerStrip.vue +55 -0
  13. package/src/client/ComputerViewerOverlay.vue +252 -0
  14. package/src/client/application.test.ts +403 -0
  15. package/src/client/application.ts +359 -0
  16. package/src/client/cordis-client-shim.d.ts +16 -0
  17. package/src/client/dialog-focus.ts +13 -0
  18. package/src/client/index.ts +28 -0
  19. package/src/client/state-machine.test.ts +200 -0
  20. package/src/client/state-machine.ts +172 -0
  21. package/src/client/styles.css +594 -0
  22. package/src/client/viewer.ts +58 -0
  23. package/src/control-record.ts +57 -0
  24. package/src/doctor.test.ts +247 -0
  25. package/src/env.d.ts +12 -0
  26. package/src/index.ts +6 -0
  27. package/src/manifest.ts +3 -0
  28. package/src/process-records.test.ts +178 -0
  29. package/src/process-records.ts +278 -0
  30. package/src/process-store.ts +96 -0
  31. package/src/processes.test.ts +388 -0
  32. package/src/protocol.ts +405 -0
  33. package/src/roots.ts +6 -0
  34. package/src/screenshot.test.ts +253 -0
  35. package/src/shared-provider.test.ts +56 -0
  36. package/src/shared-provider.ts +121 -0
  37. package/src/shared.ts +54 -0
  38. package/src/sync.test.ts +255 -0
  39. package/src/workspace-fixture.ts +126 -0
  40. package/tsconfig.json +19 -0
  41. package/vite.config.ts +24 -0
  42. package/README.md +0 -3
@@ -0,0 +1,359 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ // The hosted Computer client projects the Bot Durable Object and submits one
4
+ // versioned command per action. The viewer bearer URL is held only on this
5
+ // in-memory state object and is never copied into browser navigation state.
6
+ import type { ClientPlugin } from "@frockbot/client-core";
7
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
8
+ import { ref, watch } from "vue";
9
+ import {
10
+ decodeComputerCommandReceiptV1,
11
+ decodeComputerProjectionV1,
12
+ type ComputerCommandTypeV1,
13
+ } from "../protocol.js";
14
+ import { computerKey, type ComputerState } from "../shared.js";
15
+ import ComputerCard from "./ComputerCard.vue";
16
+ import ComputerStrip from "./ComputerStrip.vue";
17
+ import ComputerViewerOverlay from "./ComputerViewerOverlay.vue";
18
+ import {
19
+ initialComputerMachineState,
20
+ transitionComputerState,
21
+ type ComputerMachineEvent,
22
+ type ComputerMachineState,
23
+ } from "./state-machine.js";
24
+ import "./styles.css";
25
+
26
+ export const PROJECTION_POLL_INTERVAL_MS = 20_000;
27
+ export const VIEWER_REFRESH_INTERVAL_MS = 30_000;
28
+ const CONTROL_REFRESH_INTERVAL_MS = 30_000;
29
+
30
+ export interface ComputerClientRuntime {
31
+ setInterval(callback: () => void, milliseconds: number): unknown;
32
+ clearInterval(handle: unknown): void;
33
+ isVisible(): boolean;
34
+ onVisibilityChange(listener: () => void): () => void;
35
+ }
36
+
37
+ const browserRuntime: ComputerClientRuntime = {
38
+ setInterval: (callback, milliseconds) =>
39
+ globalThis.setInterval(callback, milliseconds),
40
+ clearInterval: (handle) =>
41
+ globalThis.clearInterval(handle as ReturnType<typeof setInterval>),
42
+ isVisible: () =>
43
+ typeof document === "undefined" || document.visibilityState === "visible",
44
+ onVisibilityChange: (listener) => {
45
+ if (typeof document === "undefined") return () => {};
46
+ document.addEventListener("visibilitychange", listener);
47
+ return () => document.removeEventListener("visibilitychange", listener);
48
+ },
49
+ };
50
+
51
+ function errorMessage(error: unknown): string {
52
+ return error instanceof Error ? error.message : String(error);
53
+ }
54
+
55
+ /**
56
+ * Builds the lifecycle-owned hosted provider.
57
+ *
58
+ * The runtime argument is the browser clock and visibility seam. Tests drive
59
+ * it without sleeping; production uses `document.visibilityState`, ensuring a
60
+ * hidden tab submits neither a projection poll nor a wake command (P1/P4).
61
+ */
62
+ export function createComputerClientPlugin(
63
+ runtime: ComputerClientRuntime = browserRuntime,
64
+ ): ClientPlugin {
65
+ return (ctx) => {
66
+ const slots = [
67
+ ctx.slot({
68
+ slot: "frockbot.computer",
69
+ order: 10,
70
+ component: ComputerCard,
71
+ }),
72
+ ctx.slot({
73
+ slot: "frockbot.sidebar-computer",
74
+ order: 10,
75
+ component: ComputerStrip,
76
+ }),
77
+ ctx.slot({
78
+ slot: "frockbot.overlays",
79
+ order: 20,
80
+ component: ComputerViewerOverlay,
81
+ }),
82
+ ];
83
+ // The Cordis local host publishes through `useRpc`; when no hosted
84
+ // transport exists we deliberately provide nothing, so that provider wins.
85
+ if (!ctx.transport.hostedRequest) return slots;
86
+
87
+ const request = ctx.transport.hostedRequest.bind(ctx.transport);
88
+ const shell = ctx.inject(frockBotWebDataKey);
89
+ let machine = initialComputerMachineState();
90
+ let controlHeartbeat: unknown;
91
+ let viewerHeartbeat: unknown;
92
+ let projectionPoll: unknown;
93
+ let controlRequest: Promise<void> | undefined;
94
+
95
+ const state = ref<ComputerState>({
96
+ ...machine,
97
+ connect: () => connect("connect-requested"),
98
+ openViewer: () => openViewer(),
99
+ closeViewer: () => closeViewer(),
100
+ takeControl: () => takeControl(),
101
+ releaseControl: () => releaseControl(),
102
+ runDoctor: () => execute("runDoctor"),
103
+ retry: () => connect("retry-requested"),
104
+ });
105
+
106
+ function apply(event: ComputerMachineEvent): void {
107
+ machine = transitionComputerState(machine, event);
108
+ Object.assign(state.value, machine);
109
+ syncControlHeartbeat();
110
+ syncViewerHeartbeat();
111
+ }
112
+
113
+ function stopControlHeartbeat(): void {
114
+ if (controlHeartbeat !== undefined) {
115
+ runtime.clearInterval(controlHeartbeat);
116
+ }
117
+ controlHeartbeat = undefined;
118
+ }
119
+
120
+ function syncControlHeartbeat(): void {
121
+ if (machine.phase !== "human-control") {
122
+ stopControlHeartbeat();
123
+ return;
124
+ }
125
+ if (controlHeartbeat === undefined) {
126
+ controlHeartbeat = runtime.setInterval(
127
+ () => void refreshControl(),
128
+ CONTROL_REFRESH_INTERVAL_MS,
129
+ );
130
+ }
131
+ }
132
+
133
+ function stopViewerHeartbeat(): void {
134
+ if (viewerHeartbeat !== undefined) {
135
+ runtime.clearInterval(viewerHeartbeat);
136
+ }
137
+ viewerHeartbeat = undefined;
138
+ }
139
+
140
+ function syncViewerHeartbeat(): void {
141
+ if (!machine.expanded || !machine.viewerUrl) {
142
+ stopViewerHeartbeat();
143
+ return;
144
+ }
145
+ if (viewerHeartbeat === undefined) {
146
+ // This command, not the strip and not a projection read, is the only
147
+ // client activity that keeps a watched desktop's slot live (P3).
148
+ viewerHeartbeat = runtime.setInterval(
149
+ () => void refreshViewer(),
150
+ VIEWER_REFRESH_INTERVAL_MS,
151
+ );
152
+ }
153
+ }
154
+
155
+ function stopProjectionPoll(): void {
156
+ if (projectionPoll !== undefined) runtime.clearInterval(projectionPoll);
157
+ projectionPoll = undefined;
158
+ }
159
+
160
+ function syncProjectionPoll(): void {
161
+ stopProjectionPoll();
162
+ if (!shell.value.activeBotId || !runtime.isVisible()) return;
163
+ projectionPoll = runtime.setInterval(() => {
164
+ const selectedBotId = shell.value.activeBotId;
165
+ if (!selectedBotId || !runtime.isVisible()) return;
166
+ // The `updating` record is written when the host refused a connect
167
+ // mid-update; it only changes when someone connects again. While the
168
+ // User has the Computer open, rejoin the update each poll so the
169
+ // viewer arrives the moment the host finishes (P4).
170
+ if (machine.phase === "updating" && machine.expanded) {
171
+ void execute("connect").catch(() => {
172
+ // A refusal keeps the updating phase; the next poll asks again.
173
+ });
174
+ return;
175
+ }
176
+ void load(selectedBotId).catch((error) =>
177
+ apply({ type: "failed", message: errorMessage(error) }),
178
+ );
179
+ }, PROJECTION_POLL_INTERVAL_MS);
180
+ }
181
+
182
+ function botId(): string {
183
+ const selected = shell.value.activeBotId?.trim();
184
+ if (!selected)
185
+ throw new Error("Select a Bot before opening its Computer");
186
+ return selected;
187
+ }
188
+
189
+ async function load(selectedBotId = botId()): Promise<void> {
190
+ const projection = decodeComputerProjectionV1(
191
+ await request(
192
+ `/api/bots/${encodeURIComponent(selectedBotId)}/computer`,
193
+ ),
194
+ );
195
+ if (projection.botId !== selectedBotId) {
196
+ throw new Error("Computer projection does not match the selected Bot");
197
+ }
198
+ apply({ type: "projection-received", projection });
199
+ }
200
+
201
+ async function post(type: ComputerCommandTypeV1): Promise<void> {
202
+ const selectedBotId = botId();
203
+ const receipt = decodeComputerCommandReceiptV1(
204
+ await request(
205
+ `/api/bots/${encodeURIComponent(selectedBotId)}/computer/commands`,
206
+ "POST",
207
+ JSON.stringify({
208
+ version: 1,
209
+ commandId: crypto.randomUUID(),
210
+ botId: selectedBotId,
211
+ type,
212
+ }),
213
+ ),
214
+ );
215
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
216
+ }
217
+
218
+ async function execute(type: ComputerCommandTypeV1): Promise<void> {
219
+ try {
220
+ await post(type);
221
+ await load();
222
+ } catch (error) {
223
+ // A connect can be refused because the host is already applying an
224
+ // update. The Bot authority records that typed phase even though the
225
+ // command receipt is rejected, so read its durable projection before
226
+ // deciding this is a generic client error.
227
+ try {
228
+ await load();
229
+ } catch {
230
+ // The original command failure remains the useful answer.
231
+ }
232
+ if (type === "connect" && machine.phase === "updating") return;
233
+ apply({ type: "failed", message: errorMessage(error) });
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ async function connect(
239
+ event: "connect-requested" | "retry-requested",
240
+ ): Promise<void> {
241
+ apply({ type: event });
242
+ await execute("connect");
243
+ }
244
+
245
+ async function openViewer(): Promise<void> {
246
+ if (machine.expanded) return;
247
+ // Idle wakes. Updating rejoins: the host either hands back the viewer
248
+ // because the update has finished, or refuses again with its phase.
249
+ const wake = machine.phase === "idle" || machine.phase === "updating";
250
+ apply({ type: "viewer-expanded" });
251
+ if (!wake) return;
252
+ if (machine.phase === "updating") {
253
+ await execute("connect").catch(() => {
254
+ // Still updating; the projection poll keeps rejoining.
255
+ });
256
+ return;
257
+ }
258
+ await connect("connect-requested");
259
+ }
260
+
261
+ async function closeViewer(): Promise<void> {
262
+ if (!machine.expanded) return;
263
+ if (controlRequest) {
264
+ try {
265
+ await controlRequest;
266
+ } catch {
267
+ // The failed acquisition already projected its error. There is no
268
+ // lease to release before this explicit close finishes.
269
+ }
270
+ }
271
+ if (machine.takingControl) await releaseControl();
272
+ apply({ type: "viewer-collapsed" });
273
+ }
274
+
275
+ function takeControl(): Promise<void> {
276
+ if (controlRequest) return controlRequest;
277
+ const pending = (async () => {
278
+ if (!machine.viewerUrl) await connect("connect-requested");
279
+ if (!machine.viewerUrl) return;
280
+ apply({ type: "take-control-requested" });
281
+ await execute("takeControl");
282
+ })();
283
+ controlRequest = pending.finally(() => {
284
+ controlRequest = undefined;
285
+ });
286
+ return controlRequest;
287
+ }
288
+
289
+ async function releaseControl(): Promise<void> {
290
+ await execute("releaseControl");
291
+ }
292
+
293
+ async function refreshControl(): Promise<void> {
294
+ try {
295
+ await post("refreshControl");
296
+ await load();
297
+ } catch (error) {
298
+ apply({
299
+ type: "failed",
300
+ message: `Human control lease was lost: ${errorMessage(error)}`,
301
+ takingControl: false,
302
+ });
303
+ }
304
+ }
305
+
306
+ async function refreshViewer(): Promise<void> {
307
+ try {
308
+ await post("refreshViewer");
309
+ await load();
310
+ } catch (error) {
311
+ apply({
312
+ type: "viewer-disconnected",
313
+ message: `Viewer disconnected: ${errorMessage(error)}`,
314
+ });
315
+ }
316
+ }
317
+
318
+ const stopSelection = watch(
319
+ () => shell.value.activeBotId,
320
+ (selectedBotId) => {
321
+ stopControlHeartbeat();
322
+ stopViewerHeartbeat();
323
+ machine = initialComputerMachineState();
324
+ Object.assign(state.value, machine);
325
+ syncProjectionPoll();
326
+ if (!selectedBotId || !runtime.isVisible()) return;
327
+ void load(selectedBotId).catch((error) =>
328
+ apply({ type: "failed", message: errorMessage(error) }),
329
+ );
330
+ },
331
+ { immediate: true },
332
+ );
333
+ const stopVisibility = runtime.onVisibilityChange(() => {
334
+ syncProjectionPoll();
335
+ const selectedBotId = shell.value.activeBotId;
336
+ if (!selectedBotId || !runtime.isVisible()) return;
337
+ void load(selectedBotId).catch((error) =>
338
+ apply({ type: "failed", message: errorMessage(error) }),
339
+ );
340
+ });
341
+
342
+ const provided = ctx.provide(computerKey, state);
343
+ return [
344
+ ...slots,
345
+ provided,
346
+ () => {
347
+ stopSelection();
348
+ stopVisibility();
349
+ stopControlHeartbeat();
350
+ stopViewerHeartbeat();
351
+ stopProjectionPoll();
352
+ },
353
+ ];
354
+ };
355
+ }
356
+
357
+ export const computerClientPlugin = createComputerClientPlugin();
358
+
359
+ export default computerClientPlugin;
@@ -0,0 +1,16 @@
1
+ import type { Component, Ref } from "vue";
2
+
3
+ export interface Context {
4
+ client: {
5
+ router: {
6
+ slot(options: {
7
+ type: string;
8
+ name?: string;
9
+ order?: number;
10
+ component: Component;
11
+ }): void;
12
+ };
13
+ };
14
+ }
15
+
16
+ export declare function useRpc<T>(): Ref<T>;
@@ -0,0 +1,13 @@
1
+ /** Return the wrapped focus target, or undefined when Tab stays in the dialog. */
2
+ export function dialogFocusWrapTarget<T>(
3
+ controls: readonly T[],
4
+ active: T | null,
5
+ reverse: boolean,
6
+ ): T | undefined {
7
+ const first = controls[0];
8
+ const last = controls.at(-1);
9
+ if (!first || !last) return undefined;
10
+ if (reverse && active === first) return last;
11
+ if (!reverse && active === last) return first;
12
+ return undefined;
13
+ }
@@ -0,0 +1,28 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ import type { Context } from "@cordisjs/client";
4
+ import ComputerCard from "./ComputerCard.vue";
5
+ import ComputerStrip from "./ComputerStrip.vue";
6
+ import ComputerViewerOverlay from "./ComputerViewerOverlay.vue";
7
+ import "./styles.css";
8
+
9
+ // The viewer UI is shared by every provider capable of publishing a viewer.
10
+ const computerWebPlugin = (ctx: Context) => {
11
+ ctx.client.router.slot({
12
+ type: "frockbot.computer",
13
+ order: 10,
14
+ component: ComputerCard,
15
+ });
16
+ ctx.client.router.slot({
17
+ type: "frockbot.sidebar-computer",
18
+ order: 10,
19
+ component: ComputerStrip,
20
+ });
21
+ ctx.client.router.slot({
22
+ type: "frockbot.overlays",
23
+ order: 20,
24
+ component: ComputerViewerOverlay,
25
+ });
26
+ };
27
+
28
+ export default computerWebPlugin;
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ initialComputerMachineState,
4
+ transitionComputerState,
5
+ } from "./state-machine.js";
6
+
7
+ describe("Computer client state machine", () => {
8
+ test("moves through every viewer and control transition used by the card", () => {
9
+ let state = transitionComputerState(initialComputerMachineState(), {
10
+ type: "configured",
11
+ botId: "scout",
12
+ providerLabel: "Fly Sprites",
13
+ configured: true,
14
+ message: "Persistent Fly Sprite computer",
15
+ });
16
+ expect(state.phase).toBe("idle");
17
+ state = transitionComputerState(state, { type: "connect-requested" });
18
+ expect(state.phase).toBe("provisioning");
19
+ state = transitionComputerState(state, {
20
+ type: "connected",
21
+ viewerUrl: "https://viewer.invalid/session",
22
+ });
23
+ expect(state).toMatchObject({ phase: "ready", takingControl: false });
24
+ state = transitionComputerState(state, {
25
+ type: "take-control-requested",
26
+ });
27
+ expect(state.phase).toBe("taking-control");
28
+ state = transitionComputerState(state, { type: "control-acquired" });
29
+ expect(state).toMatchObject({
30
+ phase: "human-control",
31
+ takingControl: true,
32
+ });
33
+ state = transitionComputerState(state, { type: "control-released" });
34
+ expect(state).toMatchObject({ phase: "ready", takingControl: false });
35
+ });
36
+
37
+ test("moves to error without discarding whether control was held", () => {
38
+ const state = transitionComputerState(
39
+ {
40
+ ...initialComputerMachineState(),
41
+ phase: "human-control",
42
+ takingControl: true,
43
+ },
44
+ { type: "failed", message: "lease was lost" },
45
+ );
46
+ expect(state).toMatchObject({
47
+ phase: "error",
48
+ message: "lease was lost",
49
+ takingControl: true,
50
+ });
51
+ });
52
+
53
+ test("retry returns an error state to provisioning", () => {
54
+ const failed = transitionComputerState(initialComputerMachineState(), {
55
+ type: "failed",
56
+ message: "wake failed",
57
+ });
58
+ const retried = transitionComputerState(failed, {
59
+ type: "retry-requested",
60
+ });
61
+ expect(retried).toMatchObject({
62
+ phase: "provisioning",
63
+ takingControl: false,
64
+ });
65
+ });
66
+
67
+ test("moves into updating with its label and out when connection completes", () => {
68
+ const updating = transitionComputerState(initialComputerMachineState(), {
69
+ type: "update-reported",
70
+ message: "Updating the Computer runtime",
71
+ });
72
+ const ready = transitionComputerState(updating, {
73
+ type: "connected",
74
+ viewerUrl: "https://viewer.invalid/session",
75
+ });
76
+
77
+ expect(updating).toMatchObject({
78
+ phase: "updating",
79
+ message: "Updating the Computer runtime",
80
+ viewerUrl: undefined,
81
+ });
82
+ expect(ready).toMatchObject({
83
+ phase: "ready",
84
+ message: "Computer ready",
85
+ viewerUrl: "https://viewer.invalid/session",
86
+ });
87
+ });
88
+
89
+ test("marks a dead viewer disconnected and clears the frozen session", () => {
90
+ const disconnected = transitionComputerState(
91
+ {
92
+ ...initialComputerMachineState(),
93
+ phase: "ready",
94
+ viewerUrl: "https://viewer.invalid/secret",
95
+ },
96
+ { type: "viewer-disconnected", message: "Viewer session expired" },
97
+ );
98
+
99
+ expect(disconnected).toMatchObject({
100
+ phase: "disconnected",
101
+ message: "Viewer session expired",
102
+ viewerUrl: undefined,
103
+ takingControl: false,
104
+ });
105
+ });
106
+
107
+ test("a dead viewer preserves a held control lease so close can release it", () => {
108
+ const disconnected = transitionComputerState(
109
+ {
110
+ ...initialComputerMachineState(),
111
+ phase: "human-control",
112
+ viewerUrl: "https://viewer.invalid/secret",
113
+ takingControl: true,
114
+ },
115
+ { type: "viewer-disconnected", message: "Viewer session expired" },
116
+ );
117
+
118
+ expect(disconnected).toMatchObject({
119
+ phase: "disconnected",
120
+ viewerUrl: undefined,
121
+ takingControl: true,
122
+ });
123
+ });
124
+
125
+ test("an idle strip click expands before its connect request", () => {
126
+ const idle = {
127
+ ...initialComputerMachineState(),
128
+ phase: "idle" as const,
129
+ };
130
+ const expanded = transitionComputerState(idle, {
131
+ type: "viewer-expanded",
132
+ });
133
+ const connecting = transitionComputerState(expanded, {
134
+ type: "connect-requested",
135
+ });
136
+
137
+ expect(expanded).toMatchObject({ phase: "idle", expanded: true });
138
+ expect(connecting).toMatchObject({
139
+ phase: "provisioning",
140
+ expanded: true,
141
+ });
142
+ });
143
+
144
+ test("keeps the durable capture object until its content hash changes", () => {
145
+ const first = {
146
+ version: 1 as const,
147
+ path: "scout/first.png",
148
+ capturedAt: "2026-09-02T00:00:00.000Z",
149
+ contentHash: "sha256:first",
150
+ url: "/workspace/first",
151
+ };
152
+ const state = {
153
+ ...initialComputerMachineState(),
154
+ screenshots: [first],
155
+ };
156
+ const unchanged = transitionComputerState(state, {
157
+ type: "projection-received",
158
+ projection: {
159
+ version: 1,
160
+ botId: "scout",
161
+ providerLabel: "Fake Computer",
162
+ phase: "idle",
163
+ message: "Computer available",
164
+ screenshots: [{ ...first, url: "/workspace/reissued" }],
165
+ },
166
+ });
167
+ const changed = transitionComputerState(unchanged, {
168
+ type: "projection-received",
169
+ projection: {
170
+ version: 1,
171
+ botId: "scout",
172
+ providerLabel: "Fake Computer",
173
+ phase: "idle",
174
+ message: "Computer available",
175
+ screenshots: [
176
+ {
177
+ ...first,
178
+ contentHash: "sha256:second",
179
+ url: "/workspace/second",
180
+ },
181
+ ],
182
+ },
183
+ });
184
+
185
+ expect(unchanged.screenshots[0]).toBe(first);
186
+ expect(changed.screenshots[0]).not.toBe(first);
187
+ });
188
+
189
+ test("reset explicitly clears viewer secrets from an existing projection", () => {
190
+ const projected = {
191
+ ...initialComputerMachineState(),
192
+ phase: "ready" as const,
193
+ viewerUrl: "https://viewer.invalid/secret",
194
+ };
195
+
196
+ Object.assign(projected, initialComputerMachineState());
197
+
198
+ expect(projected.viewerUrl).toBeUndefined();
199
+ });
200
+ });