@frockbot/plugin-computer 0.2.4 → 0.3.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.
package/src/protocol.ts CHANGED
@@ -9,6 +9,7 @@ export const COMPUTER_COMMAND_TYPES = [
9
9
  "releaseControl",
10
10
  "refreshControl",
11
11
  "refreshViewer",
12
+ "closeViewer",
12
13
  "runDoctor",
13
14
  ] as const;
14
15
 
@@ -70,6 +71,15 @@ export interface ComputerProgressStepViewV1 {
70
71
  status: ComputerProgressStepStatusV1;
71
72
  }
72
73
 
74
+ export interface ComputerProvisioningProgressViewV1 {
75
+ version: 1;
76
+ kind: "provision" | "update";
77
+ label: string;
78
+ index: number;
79
+ total: number;
80
+ resumed: boolean;
81
+ }
82
+
73
83
  /** Durable progress projected from the Bot authority; it contains no secrets. */
74
84
  export interface ComputerProgressViewV1 {
75
85
  version: 1;
@@ -78,6 +88,7 @@ export interface ComputerProgressViewV1 {
78
88
  updatedAt: string;
79
89
  index: number;
80
90
  total: number;
91
+ provisioning?: ComputerProvisioningProgressViewV1;
81
92
  steps: ComputerProgressStepViewV1[];
82
93
  }
83
94
 
@@ -136,6 +147,22 @@ export type ComputerCommandReceiptV1 =
136
147
  failure: string;
137
148
  };
138
149
 
150
+ /**
151
+ * A connect command has been durably admitted and scheduled. Version 2 is a
152
+ * distinct wire shape from the terminal version 1 receipt: it never claims
153
+ * that the Computer effect has completed.
154
+ */
155
+ export interface ComputerCommandAcceptedV2 {
156
+ version: 2;
157
+ commandId: string;
158
+ type: "connect";
159
+ status: "accepted";
160
+ admittedAt: string;
161
+ }
162
+
163
+ export type ComputerCommandResponse =
164
+ ComputerCommandReceiptV1 | ComputerCommandAcceptedV2;
165
+
139
166
  export class ComputerProtocolDecodeError extends Error {
140
167
  override readonly name = "ComputerProtocolDecodeError";
141
168
  }
@@ -238,6 +265,46 @@ function decodeProgressStepV1(value: unknown): ComputerProgressStepViewV1 {
238
265
  };
239
266
  }
240
267
 
268
+ function decodeProvisioningProgressV1(
269
+ value: unknown,
270
+ ): ComputerProvisioningProgressViewV1 {
271
+ const candidate = record(value, "Computer provisioning progress");
272
+ exactKeys(
273
+ candidate,
274
+ ["version", "kind", "label", "index", "total", "resumed"],
275
+ [],
276
+ "Computer provisioning progress",
277
+ );
278
+ if (
279
+ candidate.version !== 1 ||
280
+ (candidate.kind !== "provision" && candidate.kind !== "update") ||
281
+ typeof candidate.resumed !== "boolean"
282
+ ) {
283
+ throw new ComputerProtocolDecodeError(
284
+ "Computer provisioning progress is invalid",
285
+ );
286
+ }
287
+ const total = boundedInteger(
288
+ candidate.total,
289
+ 1,
290
+ 1_000,
291
+ "Computer provisioning progress total",
292
+ );
293
+ return {
294
+ version: 1,
295
+ kind: candidate.kind,
296
+ label: text(candidate.label, "Computer provisioning progress label"),
297
+ index: boundedInteger(
298
+ candidate.index,
299
+ 0,
300
+ total,
301
+ "Computer provisioning progress index",
302
+ ),
303
+ total,
304
+ resumed: candidate.resumed,
305
+ };
306
+ }
307
+
241
308
  export function decodeComputerProgressViewV1(
242
309
  value: unknown,
243
310
  ): ComputerProgressViewV1 {
@@ -245,7 +312,7 @@ export function decodeComputerProgressViewV1(
245
312
  exactKeys(
246
313
  candidate,
247
314
  ["version", "kind", "startedAt", "updatedAt", "index", "total", "steps"],
248
- [],
315
+ ["provisioning"],
249
316
  "Computer progress",
250
317
  );
251
318
  if (
@@ -282,6 +349,11 @@ export function decodeComputerProgressViewV1(
282
349
  updatedAt: timestamp(candidate.updatedAt, "Computer progress updatedAt"),
283
350
  index,
284
351
  total,
352
+ ...(candidate.provisioning === undefined
353
+ ? {}
354
+ : {
355
+ provisioning: decodeProvisioningProgressV1(candidate.provisioning),
356
+ }),
285
357
  steps,
286
358
  };
287
359
  }
@@ -507,6 +579,44 @@ export function decodeComputerCommandReceiptV1(
507
579
  );
508
580
  }
509
581
 
582
+ export function decodeComputerCommandResponse(
583
+ value: unknown,
584
+ ): ComputerCommandResponse {
585
+ if (
586
+ value &&
587
+ typeof value === "object" &&
588
+ !Array.isArray(value) &&
589
+ (value as Record<string, unknown>).version === 2
590
+ ) {
591
+ const candidate = record(value, "Computer command acceptance");
592
+ exactKeys(
593
+ candidate,
594
+ ["version", "commandId", "type", "status", "admittedAt"],
595
+ [],
596
+ "Computer command acceptance",
597
+ );
598
+ if (candidate.type !== "connect" || candidate.status !== "accepted") {
599
+ throw new ComputerProtocolDecodeError(
600
+ "Computer command acceptance is invalid",
601
+ );
602
+ }
603
+ return {
604
+ version: 2,
605
+ commandId: text(
606
+ candidate.commandId,
607
+ "Computer command acceptance commandId",
608
+ ),
609
+ type: "connect",
610
+ status: "accepted",
611
+ admittedAt: timestamp(
612
+ candidate.admittedAt,
613
+ "Computer command acceptance admittedAt",
614
+ ),
615
+ };
616
+ }
617
+ return decodeComputerCommandReceiptV1(value);
618
+ }
619
+
510
620
  export function computerCommandFingerprintV1(
511
621
  command: ComputerCommandV1,
512
622
  ): string {
@@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test";
9
9
  import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
10
10
  import { ToolRegistry } from "@frockbot/plugin-tools";
11
11
  import {
12
+ ComputerError,
12
13
  ComputerRegistry,
13
14
  computerBotPathKeyV1,
14
15
  type ComputerHandle,
@@ -47,12 +48,27 @@ function providerWith(
47
48
  tenant,
48
49
  workspace,
49
50
  screenshot: { capture: () => capture() },
51
+ exec: {
52
+ execute: () =>
53
+ Promise.resolve({
54
+ exitCode: 0,
55
+ stdout: new TextEncoder().encode("done"),
56
+ stderr: new Uint8Array(),
57
+ outputTruncated: false,
58
+ }),
59
+ },
50
60
  close: () => Promise.resolve(),
51
61
  }),
52
62
  };
53
63
  }
54
64
 
55
- async function mount(provider: ComputerProvider, writer = true) {
65
+ async function mount(
66
+ provider: ComputerProvider,
67
+ writer = true,
68
+ projectionFiles?: {
69
+ invalidate(botId: string, kind: "screenshots" | "doctor"): void;
70
+ },
71
+ ) {
56
72
  const harness = await createPluginHarness([
57
73
  ComputerRegistry,
58
74
  ToolRegistry,
@@ -73,13 +89,16 @@ async function mount(provider: ComputerProvider, writer = true) {
73
89
  },
74
90
  }
75
91
  : {}),
92
+ ...(projectionFiles ? { projectionFiles } : {}),
76
93
  }),
77
94
  );
78
95
  return harness;
79
96
  }
80
97
 
81
- async function capture(
98
+ async function executeTool(
82
99
  harness: Awaited<ReturnType<typeof createPluginHarness>>,
100
+ name: string,
101
+ input: unknown,
83
102
  ) {
84
103
  const context = {
85
104
  botId: "bot-1",
@@ -91,13 +110,17 @@ async function capture(
91
110
  signal: new AbortController().signal,
92
111
  };
93
112
  const prepared = await harness.root.tools.prepare(
94
- { id: crypto.randomUUID(), name: "computer_screenshot", input: {} },
113
+ { id: crypto.randomUUID(), name, input },
95
114
  context,
96
115
  );
97
116
  if (prepared.kind !== "ready") throw new Error(prepared.result.content);
98
117
  return harness.root.tools.executePrepared(prepared, context);
99
118
  }
100
119
 
120
+ function capture(harness: Awaited<ReturnType<typeof createPluginHarness>>) {
121
+ return executeTool(harness, "computer_screenshot", {});
122
+ }
123
+
101
124
  describe("computer_screenshot", () => {
102
125
  test("files the capture through the Workspace with the Bot as its writer", async () => {
103
126
  const workspace = new FakeWorkspace();
@@ -239,6 +262,80 @@ describe("computer_screenshot", () => {
239
262
  ).not.toContain("computer_screenshot");
240
263
  await harness.dispose();
241
264
  });
265
+
266
+ test("captures a final frame after a Turn that used the Computer", async () => {
267
+ const workspace = new FakeWorkspace();
268
+ const invalidations: string[] = [];
269
+ const harness = await mount(
270
+ providerWith(workspace, () =>
271
+ Promise.resolve({
272
+ bytes: png(1280, 720),
273
+ mediaType: "image/png" as const,
274
+ display: ":100",
275
+ capturedAt: "2026-09-03T00:00:10.000Z",
276
+ }),
277
+ ),
278
+ true,
279
+ {
280
+ invalidate: (botId, kind) => invalidations.push(`${botId}:${kind}`),
281
+ },
282
+ );
283
+ const session = harness.root.sessions.create("session-1");
284
+ const agent = { botId: "bot-1", session };
285
+ await harness.root.waterfall(
286
+ "agent/pre-step",
287
+ agent as never,
288
+ [],
289
+ 1,
290
+ 1,
291
+ () => Promise.resolve({ kind: "enter" as const, inputs: [] }),
292
+ );
293
+ await executeTool(harness, "computer_exec", { command: "pwd" });
294
+
295
+ await harness.root.serial("agent/turn-stopping", agent as never, 1);
296
+
297
+ expect(workspace.writes).toHaveLength(1);
298
+ expect(workspace.writes[0]?.writer).toEqual({
299
+ kind: "bot",
300
+ botId: "bot-1",
301
+ sessionId: "session-1",
302
+ turnId: "run-9",
303
+ runId: "run-9",
304
+ });
305
+ expect(invalidations).toEqual(["bot-1:screenshots"]);
306
+ await harness.dispose();
307
+ });
308
+
309
+ test("a final-frame capture is refused while the User holds control", async () => {
310
+ const workspace = new FakeWorkspace();
311
+ let captures = 0;
312
+ const harness = await mount(
313
+ providerWith(workspace, () => {
314
+ captures += 1;
315
+ return Promise.reject(
316
+ new ComputerError("human-control-active", "held by User"),
317
+ );
318
+ }),
319
+ );
320
+ const session = harness.root.sessions.create("session-1");
321
+ const agent = { botId: "bot-1", session };
322
+ await harness.root.waterfall(
323
+ "agent/pre-step",
324
+ agent as never,
325
+ [],
326
+ 1,
327
+ 1,
328
+ () => Promise.resolve({ kind: "enter" as const, inputs: [] }),
329
+ );
330
+ await executeTool(harness, "computer_exec", { command: "pwd" });
331
+
332
+ await expect(
333
+ harness.root.serial("agent/turn-stopping", agent as never, 1),
334
+ ).resolves.toBeUndefined();
335
+ expect(captures).toBe(1);
336
+ expect(workspace.writes).toHaveLength(0);
337
+ await harness.dispose();
338
+ });
242
339
  });
243
340
 
244
341
  describe("pngDimensionsV1", () => {
@@ -38,6 +38,8 @@ export class FakeWorkspace implements ComputerWorkspace {
38
38
  { bytes: Uint8Array; generation: WorkspaceGenerationV1 }
39
39
  >();
40
40
  readonly deleted: string[] = [];
41
+ readonly reads: WorkspacePathV1[] = [];
42
+ readonly lists: { root: WorkspaceRootV1; prefix?: string }[] = [];
41
43
  /**
42
44
  * Every write, in order, with the root it named.
43
45
  *
@@ -57,6 +59,7 @@ export class FakeWorkspace implements ComputerWorkspace {
57
59
  }
58
60
 
59
61
  read(path: WorkspacePathV1) {
62
+ this.reads.push(path);
60
63
  const held = this.files.get(this.key(path));
61
64
  return Promise.resolve(
62
65
  held
@@ -81,6 +84,7 @@ export class FakeWorkspace implements ComputerWorkspace {
81
84
  }
82
85
 
83
86
  list(request: { root: WorkspaceRootV1; prefix?: string }) {
87
+ this.lists.push(request);
84
88
  const entries: WorkspaceEntryV1[] = [...this.files.entries()]
85
89
  .filter(([path]) => !request.prefix || path.startsWith(request.prefix))
86
90
  .map(([path, held]) => ({
@@ -1,54 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
- import { parse } from "@vue/compiler-sfc";
4
- import {
5
- initialComputerMachineState,
6
- transitionComputerState,
7
- } from "./state-machine.js";
8
-
9
- test("the Vue strip keys its durable capture only by contentHash", () => {
10
- const source = readFileSync(
11
- new URL("./ComputerStrip.vue", import.meta.url),
12
- "utf8",
13
- );
14
- const parsed = parse(source, { filename: "ComputerStrip.vue" });
15
- expect(parsed.errors).toEqual([]);
16
- const template = parsed.descriptor.template?.content ?? "";
17
-
18
- expect(template).toContain(':key="screenshot.contentHash"');
19
- expect(template).toContain(':src="screenshot.url"');
20
- expect(template).not.toContain("viewerUrl");
21
- });
22
-
23
- test("a repeated screenshot projection preserves the rendered capture", () => {
24
- const first = {
25
- version: 1 as const,
26
- path: "scout/latest.png",
27
- capturedAt: "2026-09-02T00:00:00.000Z",
28
- contentHash: "sha256:first",
29
- url: "/workspace/first",
30
- };
31
- const state = {
32
- ...initialComputerMachineState(),
33
- screenshots: [first],
34
- };
35
- const project = (contentHash: string) => ({
36
- type: "projection-received" as const,
37
- projection: {
38
- version: 1 as const,
39
- botId: "scout",
40
- providerLabel: "Fake Computer",
41
- phase: "idle" as const,
42
- message: "Computer available",
43
- screenshots: [
44
- { ...first, contentHash, url: `/workspace/${contentHash}` },
45
- ],
46
- },
47
- });
48
-
49
- const unchanged = transitionComputerState(state, project("sha256:first"));
50
- const changed = transitionComputerState(unchanged, project("sha256:second"));
51
-
52
- expect(unchanged.screenshots[0]).toBe(first);
53
- expect(changed.screenshots[0]).not.toBe(first);
54
- });
@@ -1,55 +0,0 @@
1
- <script setup lang="ts">
2
- import { useRpc } from "@cordisjs/client";
3
- import { UiIcon } from "@frockbot/client-ui";
4
- import { computed, inject, ref } from "vue";
5
- import { computerKey, type ComputerState } from "../shared.ts";
6
-
7
- const computer = inject(computerKey) ?? useRpc<ComputerState>();
8
- const busy = ref(false);
9
- const state = computed(() => computer.value);
10
- const screenshot = computed(() => state.value.screenshots?.[0]);
11
- const phaseLabel = computed(() =>
12
- state.value.phase === "updating"
13
- ? state.value.message
14
- : state.value.phase.replaceAll("-", " "),
15
- );
16
-
17
- async function open(): Promise<void> {
18
- if (busy.value) return;
19
- busy.value = true;
20
- try {
21
- await state.value.openViewer();
22
- } catch {
23
- // The shared state already holds the visible failure.
24
- } finally {
25
- busy.value = false;
26
- }
27
- }
28
- </script>
29
-
30
- <template>
31
- <button
32
- type="button"
33
- class="computer-strip"
34
- :disabled="busy"
35
- :aria-label="`Open Computer, ${phaseLabel}`"
36
- @click="open"
37
- >
38
- <span class="computer-strip-capture">
39
- <img
40
- v-if="screenshot"
41
- :key="screenshot.contentHash"
42
- :src="screenshot.url"
43
- alt=""
44
- draggable="false"
45
- />
46
- <span v-else class="computer-strip-placeholder" aria-hidden="true">
47
- <UiIcon name="sparkle" size="sm" />
48
- </span>
49
- </span>
50
- <span class="computer-strip-phase">
51
- <span class="computer-strip-dot" :class="`phase-${state.phase}`" />
52
- <span>{{ phaseLabel }}</span>
53
- </span>
54
- </button>
55
- </template>