@frockbot/plugin-computer 0.3.20 → 0.3.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-computer",
3
- "version": "0.3.20",
3
+ "version": "0.3.21",
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.3.20",
33
- "@frockbot/client-ui": "0.3.20",
34
- "@frockbot/computer-core": "0.3.20",
35
- "@frockbot/computer-host-runtime": "0.3.20",
36
- "@frockbot/kernel-agent-loop": "0.3.20",
37
- "@frockbot/kernel-contracts": "0.3.20",
38
- "@frockbot/plugin-prompt": "0.3.20",
39
- "@frockbot/plugin-shell": "0.3.20",
40
- "@frockbot/plugin-tools": "0.3.20",
32
+ "@frockbot/client-core": "0.3.21",
33
+ "@frockbot/client-ui": "0.3.21",
34
+ "@frockbot/computer-core": "0.3.21",
35
+ "@frockbot/computer-host-runtime": "0.3.21",
36
+ "@frockbot/kernel-agent-loop": "0.3.21",
37
+ "@frockbot/kernel-contracts": "0.3.21",
38
+ "@frockbot/plugin-prompt": "0.3.21",
39
+ "@frockbot/plugin-shell": "0.3.21",
40
+ "@frockbot/plugin-tools": "0.3.21",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "vue": "3.5.41"
43
43
  },
44
44
  "devDependencies": {
45
- "@frockbot/plugin-models": "0.3.20",
46
- "@frockbot/plugin-testkit": "0.3.20",
45
+ "@frockbot/plugin-models": "0.3.21",
46
+ "@frockbot/plugin-testkit": "0.3.21",
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.test.ts CHANGED
@@ -14,6 +14,7 @@ import { SessionStore } from "@frockbot/kernel-contracts";
14
14
  import manifest from "../frockbot.json" with { type: "json" };
15
15
  import packageJson from "../package.json" with { type: "json" };
16
16
  import {
17
+ COMPUTER_OVERLOADED_TOOL_MESSAGE_V1,
17
18
  createComputerAgentPlugin,
18
19
  HUMAN_CONTROL_PROMPT_LINE,
19
20
  } from "./agent.js";
@@ -223,6 +224,50 @@ describe("computer agent contribution", () => {
223
224
  await harness.dispose();
224
225
  });
225
226
 
227
+ test("computer_exec maps overloaded transport failures to one bounded plain reason", async () => {
228
+ for (const message of [
229
+ "Computer command failed: WebSocket keepalive timeout after 45000ms",
230
+ "The Computer effect was cancelled",
231
+ ]) {
232
+ const provider: ComputerProvider = {
233
+ id: "fixture",
234
+ open: async (identity, tenant, assignment) => ({
235
+ assignment,
236
+ identity,
237
+ tenant,
238
+ exec: {
239
+ execute: () => Promise.reject(new Error(message)),
240
+ },
241
+ close: () => Promise.resolve(),
242
+ }),
243
+ };
244
+ const harness = await createPluginHarness([
245
+ ComputerRegistry,
246
+ ToolRegistry,
247
+ SystemPromptRegistry,
248
+ SessionStore,
249
+ ]);
250
+ harness.root.computers.register(provider);
251
+ await harness.mount(
252
+ createComputerAgentPlugin({
253
+ userId: "user-1",
254
+ defaultProviderId: "fixture",
255
+ }),
256
+ );
257
+
258
+ await expect(
259
+ execute(harness, "computer_exec", { command: "pwd" }),
260
+ ).resolves.toEqual({
261
+ content: COMPUTER_OVERLOADED_TOOL_MESSAGE_V1,
262
+ isError: true,
263
+ });
264
+ expect(COMPUTER_OVERLOADED_TOOL_MESSAGE_V1.length).toBeLessThanOrEqual(
265
+ 160,
266
+ );
267
+ await harness.dispose();
268
+ }
269
+ });
270
+
226
271
  test("injects and records the human-control line only while the durable lease is fresh", async () => {
227
272
  const records = new Map<string, unknown>([
228
273
  [
package/src/agent.ts CHANGED
@@ -153,6 +153,48 @@ export interface ComputerAgentPluginConfig {
153
153
  export const HUMAN_CONTROL_PROMPT_LINE =
154
154
  "Your User is currently controlling the Computer; do not use it this Turn.";
155
155
 
156
+ /** Bounded copy for the two transport failures an overloaded Sprite emits. */
157
+ export const COMPUTER_OVERLOADED_TOOL_MESSAGE_V1 =
158
+ "The Computer is overloaded; a browser tab using too much memory was closed. Try again.";
159
+
160
+ function errorMessage(error: unknown): string {
161
+ return error instanceof Error ? error.message : String(error);
162
+ }
163
+
164
+ function isOverloadedTransportFailure(error: unknown): boolean {
165
+ const message = errorMessage(error).toLowerCase();
166
+ return (
167
+ message.includes("websocket keepalive timeout") ||
168
+ message.includes("computer effect was cancelled")
169
+ );
170
+ }
171
+
172
+ /** A local HTTP origin an Applet preview can own; public sites never qualify. */
173
+ export function localPreviewOriginV1(value: string): string | undefined {
174
+ try {
175
+ const url = new URL(value);
176
+ const local = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
177
+ return local && (url.protocol === "http:" || url.protocol === "https:")
178
+ ? url.origin
179
+ : undefined;
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ }
184
+
185
+ function localPreviewOriginsV1(value: string): string[] {
186
+ const found = new Set<string>();
187
+ for (const match of value.matchAll(/https?:\/\/[^\s"'<>]+/g)) {
188
+ const origin = localPreviewOriginV1(match[0].replace(/[),.;!?]+$/g, ""));
189
+ if (origin) found.add(origin);
190
+ }
191
+ return [...found];
192
+ }
193
+
194
+ function runsAppletPreviewV1(command: string): boolean {
195
+ return /(?:^|[\s;&|])(?:[^\s;&|]*\/)?applet\s+dev(?:\s|$)/.test(command);
196
+ }
197
+
156
198
  /** The wake-free, Turn-scoped projection shared by prompt render and its log. */
157
199
  class ComputerControlPromptProjection {
158
200
  #line = "";
@@ -345,6 +387,9 @@ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
345
387
  }
346
388
 
347
389
  function failure(error: unknown): { content: string; isError: true } {
390
+ if (isOverloadedTransportFailure(error)) {
391
+ return { content: COMPUTER_OVERLOADED_TOOL_MESSAGE_V1, isError: true };
392
+ }
348
393
  if (error instanceof ComputerError) {
349
394
  if (error.code === "human-control-active") {
350
395
  // The holder is named, so a second Bot of the same User — and the User
@@ -367,7 +412,7 @@ function failure(error: unknown): { content: string; isError: true } {
367
412
  return { content: error.message, isError: true };
368
413
  }
369
414
  return {
370
- content: error instanceof Error ? error.message : String(error),
415
+ content: errorMessage(error),
371
416
  isError: true,
372
417
  };
373
418
  }
@@ -621,6 +666,8 @@ export function createComputerAgentPlugin(
621
666
  // Agent loop knows it; a tool context does not, so it is caught where the
622
667
  // loop already announces it.
623
668
  let currentTurn = 1;
669
+ /** Local preview origins this Bot navigated to during the current Turn. */
670
+ const previewOrigins = new Set<string>();
624
671
  // One cadence per mounted plugin, which is one per Turn: a Turn's first
625
672
  // Computer action always gets its capture, and the rest are debounced.
626
673
  const progressCadence = createComputerCaptureCadenceV1(
@@ -660,6 +707,24 @@ export function createComputerAgentPlugin(
660
707
  await selfCheck(computer, botId, signal);
661
708
  return computer;
662
709
  };
710
+ const closePreviewTabs = async (
711
+ computer: ComputerHandle,
712
+ origins: readonly string[],
713
+ effectId: string,
714
+ signal?: AbortSignal,
715
+ ): Promise<void> => {
716
+ if (!computer.browser) return;
717
+ const unique = [...new Set(origins)];
718
+ for (let offset = 0; offset < unique.length; offset += 16) {
719
+ await computer.browser.perform(
720
+ { type: "close-origins", origins: unique.slice(offset, offset + 16) },
721
+ {
722
+ ...(signal ? { signal } : {}),
723
+ effectId: `${effectId}:${Math.floor(offset / 16)}`,
724
+ },
725
+ );
726
+ }
727
+ };
663
728
 
664
729
  const execTool: ToolDefinition = {
665
730
  name: "computer_exec",
@@ -1039,6 +1104,22 @@ export function createComputerAgentPlugin(
1039
1104
  // A mirror that could not be written never withholds an outcome
1040
1105
  // that was read.
1041
1106
  }
1107
+ if (
1108
+ action === "stop" &&
1109
+ runsAppletPreviewV1(held.command) &&
1110
+ computer.browser
1111
+ ) {
1112
+ const origins = localPreviewOriginsV1(observed.logTail);
1113
+ if (origins.length > 0) {
1114
+ await closePreviewTabs(
1115
+ computer,
1116
+ origins,
1117
+ `${context.effectId}:close-preview-tabs`,
1118
+ context.signal,
1119
+ );
1120
+ for (const origin of origins) previewOrigins.delete(origin);
1121
+ }
1122
+ }
1042
1123
  if (action === "logs") {
1043
1124
  return {
1044
1125
  content: observed.logTail || "(no output yet)",
@@ -1355,7 +1436,7 @@ export function createComputerAgentPlugin(
1355
1436
  subagentRoles: ["executor", "computerUse"],
1356
1437
  },
1357
1438
  description:
1358
- "Run the Computer's self-check and read the report: disk, the shared scratch, the desktop gateway, your display, the browser profile and what the browser announces itself as, the durable-root sync and its conflicts, the reference docs, the browser launcher, the clock, and DNS. Read-only; it changes nothing and repairs nothing.",
1439
+ "Run the Computer's self-check and read the report: disk, the shared scratch, the desktop gateway, your display, the browser profile, renderer-watchdog actions, top memory consumers, the durable-root sync and its conflicts, the reference docs, the browser launcher, the clock, and DNS. Read-only; it changes nothing and repairs nothing.",
1359
1440
  inputSchema: {
1360
1441
  type: "object",
1361
1442
  properties: {},
@@ -1575,6 +1656,10 @@ export function createComputerAgentPlugin(
1575
1656
  signal: context.signal,
1576
1657
  effectId: context.effectId,
1577
1658
  });
1659
+ if (action.type === "navigate") {
1660
+ const origin = localPreviewOriginV1(action.url);
1661
+ if (origin) previewOrigins.add(origin);
1662
+ }
1578
1663
  await fileProgressCapture(computer, context.botId, context);
1579
1664
  return {
1580
1665
  content: result.accessibilitySnapshot,
@@ -1605,6 +1690,7 @@ export function createComputerAgentPlugin(
1605
1690
  ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
1606
1691
  if (turn !== currentTurn) {
1607
1692
  projectionWrites.clear();
1693
+ previewOrigins.clear();
1608
1694
  // Every Turn's first Computer action is worth a capture, however
1609
1695
  // soon after the previous Turn's last one it happens.
1610
1696
  progressCadence.reset();
@@ -1630,6 +1716,15 @@ export function createComputerAgentPlugin(
1630
1716
  return;
1631
1717
  }
1632
1718
  try {
1719
+ if (computer.browser && previewOrigins.size > 0) {
1720
+ const origins = [...previewOrigins];
1721
+ await closePreviewTabs(
1722
+ computer,
1723
+ origins,
1724
+ `computer:${writer?.runId ?? agent.session.id}:${turn}:close-preview-tabs`,
1725
+ );
1726
+ previewOrigins.clear();
1727
+ }
1633
1728
  if (writer && computer.workspace) {
1634
1729
  const root: WorkspaceRootV1 = {
1635
1730
  kind: "package-declared",
@@ -1681,7 +1776,7 @@ export function createComputerAgentPlugin(
1681
1776
  "Use computer_exec to inspect the filesystem before claiming that a path or file exists.",
1682
1777
  "Use computer_screenshot to see your own desktop; each capture is filed in your durable screenshots root.",
1683
1778
  "For a job that outlasts this Turn, use computer_exec with background:true and check it later with computer_process_check. Do not poll it in a loop.",
1684
- "Use computer_doctor when the Computer misbehaves; it reports disk, desktop, sync, and network in one read-only call.",
1779
+ "Use computer_doctor when the Computer misbehaves; it reports disk, desktop, renderer-watchdog actions, top memory consumers, sync, and network in one read-only call.",
1685
1780
  ...(controlPrompt?.current() ? [controlPrompt.current()] : []),
1686
1781
  "Never invent a directory listing.",
1687
1782
  ].join("\n"),
@@ -92,6 +92,20 @@ function fakeComputer(options: { launchFails?: boolean } = {}): Computer {
92
92
  },
93
93
  generation: () => Promise.resolve(computer.generation),
94
94
  },
95
+ browser: {
96
+ perform: (action) => {
97
+ const cleanup = action as {
98
+ type: string;
99
+ origins?: readonly string[];
100
+ };
101
+ calls.push(
102
+ cleanup.type === "close-origins"
103
+ ? `browser:close-origins:${cleanup.origins?.join(",") ?? ""}`
104
+ : `browser:${cleanup.type}`,
105
+ );
106
+ return Promise.resolve({ accessibilitySnapshot: "" });
107
+ },
108
+ },
95
109
  close: () => Promise.resolve(),
96
110
  }),
97
111
  },
@@ -418,4 +432,69 @@ describe("stopping a background process", () => {
418
432
  ]);
419
433
  await harness.dispose();
420
434
  });
435
+
436
+ test("closes the Bot's tabs on an applet dev origin when its process stops", async () => {
437
+ const computer = fakeComputer();
438
+ const held = storage();
439
+ const harness = await mount(computer, held);
440
+ const launched = JSON.parse(
441
+ (
442
+ await call(harness, "computer_exec", {
443
+ command: "applet dev --port 8787",
444
+ background: true,
445
+ })
446
+ ).content,
447
+ ) as { processId: string };
448
+ await call(harness, "computer_browser", {
449
+ action: "navigate",
450
+ url: "http://127.0.0.1:8787/",
451
+ });
452
+ computer.state = {
453
+ alive: false,
454
+ exitCode: 143,
455
+ logTail: "http://127.0.0.1:8787/\nterminated",
456
+ };
457
+
458
+ const stopped = await call(harness, "computer_process_stop", {
459
+ processId: launched.processId,
460
+ });
461
+
462
+ expect(stopped.isError).toBe(false);
463
+ expect(computer.calls).toEqual([
464
+ `launch:${launched.processId}:applet dev --port 8787`,
465
+ "browser:navigate",
466
+ `stop:${launched.processId}`,
467
+ "browser:close-origins:http://127.0.0.1:8787",
468
+ ]);
469
+ await harness.dispose();
470
+ });
471
+ });
472
+
473
+ describe("releasing a Bot's Turn", () => {
474
+ test("closes local preview origins the Bot navigated during the Turn", async () => {
475
+ const computer = fakeComputer();
476
+ const harness = await mount(computer, storage());
477
+ const session = harness.root.sessions.create("session-1");
478
+ const agent = { botId: "bot-1", session };
479
+ await harness.root.waterfall(
480
+ "agent/pre-step",
481
+ agent as never,
482
+ [],
483
+ 1,
484
+ 1,
485
+ () => Promise.resolve({ kind: "enter" as const, inputs: [] }),
486
+ );
487
+ await call(harness, "computer_browser", {
488
+ action: "navigate",
489
+ url: "http://localhost:8787/preview",
490
+ });
491
+
492
+ await harness.root.serial("agent/turn-stopping", agent as never, 1);
493
+
494
+ expect(computer.calls).toEqual([
495
+ "browser:navigate",
496
+ "browser:close-origins:http://localhost:8787",
497
+ ]);
498
+ await harness.dispose();
499
+ });
421
500
  });