@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/frockbot.json CHANGED
@@ -15,7 +15,6 @@
15
15
  "entry": "./client",
16
16
  "mounts": [
17
17
  { "slot": "frockbot.computer" },
18
- { "slot": "frockbot.sidebar-computer" },
19
18
  { "slot": "frockbot.overlays" }
20
19
  ],
21
20
  "outlets": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-computer",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,21 +29,21 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@cordisjs/client": "0.8.2",
32
- "@frockbot/client-core": "0.2.4",
33
- "@frockbot/client-ui": "0.2.4",
34
- "@frockbot/computer-core": "0.2.4",
35
- "@frockbot/computer-host-runtime": "0.2.4",
36
- "@frockbot/kernel-agent-loop": "0.2.4",
37
- "@frockbot/kernel-contracts": "0.2.4",
38
- "@frockbot/plugin-prompt": "0.2.4",
39
- "@frockbot/plugin-shell": "0.2.4",
40
- "@frockbot/plugin-tools": "0.2.4",
32
+ "@frockbot/client-core": "0.3.0",
33
+ "@frockbot/client-ui": "0.3.0",
34
+ "@frockbot/computer-core": "0.3.0",
35
+ "@frockbot/computer-host-runtime": "0.3.0",
36
+ "@frockbot/kernel-agent-loop": "0.3.0",
37
+ "@frockbot/kernel-contracts": "0.3.0",
38
+ "@frockbot/plugin-prompt": "0.3.0",
39
+ "@frockbot/plugin-shell": "0.3.0",
40
+ "@frockbot/plugin-tools": "0.3.0",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "vue": "3.5.41"
43
43
  },
44
44
  "devDependencies": {
45
- "@frockbot/plugin-models": "0.2.4",
46
- "@frockbot/plugin-testkit": "0.2.4",
45
+ "@frockbot/plugin-models": "0.3.0",
46
+ "@frockbot/plugin-testkit": "0.3.0",
47
47
  "@types/bun": "1.4.0",
48
48
  "@types/node": "26.2.0",
49
49
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.ts CHANGED
@@ -71,8 +71,12 @@ import {
71
71
  import {
72
72
  COMPUTER_DOCTOR_ROOT_ID,
73
73
  COMPUTER_SCREENSHOTS_ROOT_ID,
74
- COMPUTER_SCREENSHOT_RETENTION,
75
74
  } from "./roots.js";
75
+ import {
76
+ fileComputerScreenshotV1,
77
+ type ComputerProjectionFileInvalidationV1,
78
+ type ComputerProjectionFileKindV1,
79
+ } from "./capture.js";
76
80
  import {
77
81
  COMPUTER_CONTROL_RECORD_KEY,
78
82
  decodeStoredComputerControlV1,
@@ -86,6 +90,10 @@ export {
86
90
  } from "./roots.js";
87
91
 
88
92
  export type { ComputerProcessStorageV1 };
93
+ export type {
94
+ ComputerProjectionFileInvalidationV1,
95
+ ComputerProjectionFileKindV1,
96
+ } from "./capture.js";
89
97
 
90
98
  /**
91
99
  * The Session and Turn a durable Workspace write records as its writer.
@@ -122,6 +130,8 @@ export interface ComputerAgentPluginConfig {
122
130
  get<T>(key: string): Promise<T | undefined>;
123
131
  now?(): Date;
124
132
  };
133
+ /** Drops resident projection caches after this Turn's Workspace sync. */
134
+ projectionFiles?: ComputerProjectionFileInvalidationV1;
125
135
  }
126
136
 
127
137
  export const HUMAN_CONTROL_PROMPT_LINE =
@@ -413,46 +423,6 @@ class ComputerTurnSync {
413
423
  }
414
424
  }
415
425
 
416
- /**
417
- * Keeps the newest {@link COMPUTER_SCREENSHOT_RETENTION} captures for one Bot.
418
- *
419
- * A screenshot of a logged-in page is User-scoped content that outlives its
420
- * Turn, so the root is bounded rather than allowed to grow for the life of the
421
- * Computer. Pruning is a best effort: a capture that was recorded is never
422
- * failed because an older one could not be removed.
423
- */
424
- async function prune(
425
- workspace: NonNullable<ComputerHandle["workspace"]>,
426
- root: WorkspaceRootV1,
427
- botKey: string,
428
- writer: WorkspaceWriterV1,
429
- ): Promise<void> {
430
- const listed = await workspace.list({
431
- root,
432
- prefix: botKey,
433
- limit: COMPUTER_SCREENSHOT_RETENTION * 4,
434
- });
435
- if (listed.status !== "ok") return;
436
- // Ordered by when each capture was written, not by its path: a path names
437
- // the Turn and the ordinal within it, and neither sorts chronologically
438
- // across Turns. The generation is the record of when, so it decides.
439
- const sorted = [...listed.entries].sort((left, right) => {
440
- const order = left.generation.writtenAt.localeCompare(
441
- right.generation.writtenAt,
442
- );
443
- return order !== 0 ? order : left.path.path.localeCompare(right.path.path);
444
- });
445
- const excess = sorted.length - COMPUTER_SCREENSHOT_RETENTION;
446
- for (let index = 0; index < excess; index += 1) {
447
- const entry = sorted[index]!;
448
- await workspace.delete({
449
- path: entry.path,
450
- writer,
451
- expectedGenerationId: entry.generation.generationId,
452
- });
453
- }
454
- }
455
-
456
426
  async function useComputer<T>(
457
427
  computer: ComputerHandle,
458
428
  run: (computer: ComputerHandle) => Promise<T>,
@@ -486,6 +456,16 @@ export function createComputerAgentPlugin(
486
456
  // Agent loop knows it; a tool context does not, so it is caught where the
487
457
  // loop already announces it.
488
458
  let currentTurn = 1;
459
+ const projectionWrites = new Set<ComputerProjectionFileKindV1>();
460
+ const noteProjectionWrite = (kind: ComputerProjectionFileKindV1): void => {
461
+ projectionWrites.add(kind);
462
+ };
463
+ const invalidateProjectionWrites = (botId: string): void => {
464
+ for (const kind of projectionWrites) {
465
+ config.projectionFiles?.invalidate(botId, kind);
466
+ }
467
+ projectionWrites.clear();
468
+ };
489
469
  const turnOf = (_context: ToolExecutionContext): number => currentTurn;
490
470
  const attach = async (botId: string, signal: AbortSignal) => {
491
471
  if (!ctx.computers.assignment(identity)) {
@@ -953,12 +933,6 @@ export function createComputerAgentPlugin(
953
933
  return await useComputer(
954
934
  await open(context.botId, context.sessionId, context.signal),
955
935
  async (computer) => {
956
- if (!computer.screenshot) {
957
- throw new ComputerError(
958
- "capability-unavailable",
959
- "The selected Computer does not support screenshots",
960
- );
961
- }
962
936
  const workspace = computer.workspace;
963
937
  if (!workspace) {
964
938
  throw new ComputerError(
@@ -966,10 +940,6 @@ export function createComputerAgentPlugin(
966
940
  "The selected Computer exposes no Workspace to file a screenshot in",
967
941
  );
968
942
  }
969
- const captured = await computer.screenshot.capture({
970
- signal: context.signal,
971
- effectId: context.effectId,
972
- });
973
943
  const root: WorkspaceRootV1 = {
974
944
  kind: "package-declared",
975
945
  userId,
@@ -989,27 +959,23 @@ export function createComputerAgentPlugin(
989
959
  turnId: writer.turnId,
990
960
  runId: writer.runId,
991
961
  };
992
- const written = await workspace.write({
962
+ const filed = await fileComputerScreenshotV1({
963
+ computer,
964
+ workspace,
993
965
  path,
994
- bytes: captured.bytes,
995
966
  writer: botWriter,
996
- expectedGenerationId: null,
997
- mediaType: captured.mediaType,
967
+ botKey,
968
+ signal: context.signal,
969
+ effectId: context.effectId,
998
970
  });
999
- if (written.status !== "ok") {
1000
- return {
1001
- content: `The screenshot could not be filed: ${written.status}: ${written.reason}`,
1002
- isError: true,
1003
- };
1004
- }
1005
- await prune(workspace, root, botKey, botWriter);
1006
- const dimensions = pngDimensionsV1(captured.bytes);
971
+ noteProjectionWrite("screenshots");
972
+ const dimensions = pngDimensionsV1(filed.captured.bytes);
1007
973
  const attachment: ToolAttachmentV1 = {
1008
974
  kind: "image",
1009
- mediaType: captured.mediaType,
975
+ mediaType: filed.captured.mediaType,
1010
976
  workspacePath: path,
1011
- contentHash: written.generation.contentHash,
1012
- bytes: written.generation.size,
977
+ contentHash: filed.generation.contentHash,
978
+ bytes: filed.generation.size,
1013
979
  };
1014
980
  // The bytes are offered to the resident Session so this Turn's
1015
981
  // next model request can show them. They are never recorded:
@@ -1019,7 +985,7 @@ export function createComputerAgentPlugin(
1019
985
  .get(context.sessionId)
1020
986
  ?.offerAttachmentBytes(
1021
987
  attachment.contentHash,
1022
- base64Of(captured.bytes),
988
+ base64Of(filed.captured.bytes),
1023
989
  );
1024
990
  return {
1025
991
  content: JSON.stringify({
@@ -1028,8 +994,8 @@ export function createComputerAgentPlugin(
1028
994
  contentHash: attachment.contentHash,
1029
995
  bytes: attachment.bytes,
1030
996
  ...(dimensions ?? {}),
1031
- display: captured.display,
1032
- capturedAt: captured.capturedAt,
997
+ display: filed.captured.display,
998
+ capturedAt: filed.captured.capturedAt,
1033
999
  }),
1034
1000
  isError: false,
1035
1001
  attachments: [attachment],
@@ -1081,7 +1047,9 @@ export function createComputerAgentPlugin(
1081
1047
  : null,
1082
1048
  mediaType: "application/json",
1083
1049
  });
1084
- return written.status === "ok" ? path : undefined;
1050
+ if (written.status !== "ok") return undefined;
1051
+ noteProjectionWrite("doctor");
1052
+ return path;
1085
1053
  };
1086
1054
 
1087
1055
  /**
@@ -1368,6 +1336,7 @@ export function createComputerAgentPlugin(
1368
1336
  // A Turn's first step is where the Turn's sync state begins; a Turn that
1369
1337
  // never touches the Computer never syncs and never wakes one.
1370
1338
  ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
1339
+ if (turn !== currentTurn) projectionWrites.clear();
1371
1340
  currentTurn = turn;
1372
1341
  turnSync.beginTurn(turn);
1373
1342
  if (controlPrompt?.loadedTurn() !== turn) {
@@ -1385,13 +1354,48 @@ export function createComputerAgentPlugin(
1385
1354
  computer = await attach(agent.botId, new AbortController().signal);
1386
1355
  } catch (error) {
1387
1356
  await turnSync.unavailable(agent.session.id, error);
1357
+ invalidateProjectionWrites(agent.botId);
1388
1358
  return;
1389
1359
  }
1390
1360
  try {
1361
+ if (writer && computer.workspace) {
1362
+ const root: WorkspaceRootV1 = {
1363
+ kind: "package-declared",
1364
+ userId,
1365
+ packageId: "computer",
1366
+ rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
1367
+ };
1368
+ const botKey = computerBotPathKeyV1(agent.botId);
1369
+ captureSequence += 1;
1370
+ try {
1371
+ await fileComputerScreenshotV1({
1372
+ computer,
1373
+ workspace: computer.workspace,
1374
+ path: {
1375
+ root,
1376
+ path: `${botKey}/${writer.turnId}-${captureSequence}.png`,
1377
+ },
1378
+ writer: {
1379
+ kind: "bot",
1380
+ botId: agent.botId,
1381
+ sessionId: writer.sessionId,
1382
+ turnId: writer.turnId,
1383
+ runId: writer.runId,
1384
+ },
1385
+ botKey,
1386
+ effectId: `computer:${writer.runId}:turn-end-screenshot`,
1387
+ });
1388
+ noteProjectionWrite("screenshots");
1389
+ } catch {
1390
+ // Opportunistic capture never changes the Turn outcome. The
1391
+ // provider's human-control refusal is deliberately preserved.
1392
+ }
1393
+ }
1391
1394
  await turnSync.afterTurn(computer, agent.session.id);
1392
1395
  } catch (error) {
1393
1396
  await turnSync.unavailable(agent.session.id, error);
1394
1397
  } finally {
1398
+ invalidateProjectionWrites(agent.botId);
1395
1399
  await computer.close();
1396
1400
  }
1397
1401
  }),
package/src/backend.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  import type { Plugin } from "cordis";
5
5
  import {
6
6
  ComputerProtocolDecodeError,
7
- decodeComputerCommandReceiptV1,
7
+ decodeComputerCommandResponse,
8
8
  decodeComputerCommandV1,
9
9
  decodeComputerProjectionV1,
10
- type ComputerCommandReceiptV1,
10
+ type ComputerCommandResponse,
11
11
  type ComputerCommandV1,
12
12
  type ComputerProjectionV1,
13
13
  } from "./protocol.js";
@@ -18,7 +18,7 @@ export interface ComputerGatewayHost {
18
18
  userId: string,
19
19
  botId: string,
20
20
  command: ComputerCommandV1,
21
- ): Promise<ComputerCommandReceiptV1>;
21
+ ): Promise<ComputerCommandResponse>;
22
22
  }
23
23
 
24
24
  export interface ComputerBackendRouteContribution {
@@ -144,7 +144,7 @@ export function createComputerBackendContribution(
144
144
  );
145
145
  }
146
146
  return Response.json(
147
- decodeComputerCommandReceiptV1(
147
+ decodeComputerCommandResponse(
148
148
  await host.executeComputerCommand(context.userId, botId, decoded),
149
149
  ),
150
150
  );