@frockbot/plugin-computer 0.3.10 → 0.3.12

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.10",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,25 +29,25 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@cordisjs/client": "0.8.2",
32
- "@frockbot/client-core": "0.3.10",
33
- "@frockbot/client-ui": "0.3.10",
34
- "@frockbot/computer-core": "0.3.10",
35
- "@frockbot/computer-host-runtime": "0.3.10",
36
- "@frockbot/kernel-agent-loop": "0.3.10",
37
- "@frockbot/kernel-contracts": "0.3.10",
38
- "@frockbot/plugin-prompt": "0.3.10",
39
- "@frockbot/plugin-shell": "0.3.10",
40
- "@frockbot/plugin-tools": "0.3.10",
32
+ "@frockbot/client-core": "0.3.12",
33
+ "@frockbot/client-ui": "0.3.12",
34
+ "@frockbot/computer-core": "0.3.12",
35
+ "@frockbot/computer-host-runtime": "0.3.12",
36
+ "@frockbot/kernel-agent-loop": "0.3.12",
37
+ "@frockbot/kernel-contracts": "0.3.12",
38
+ "@frockbot/plugin-prompt": "0.3.12",
39
+ "@frockbot/plugin-shell": "0.3.12",
40
+ "@frockbot/plugin-tools": "0.3.12",
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.10",
46
- "@frockbot/plugin-testkit": "0.3.10",
45
+ "@frockbot/plugin-models": "0.3.12",
46
+ "@frockbot/plugin-testkit": "0.3.12",
47
47
  "@types/bun": "1.4.0",
48
48
  "@types/node": "26.2.0",
49
49
  "@vitejs/plugin-vue": "6.0.8",
50
- "typescript": "5.9.3",
50
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
51
51
  "vite": "8.2.2",
52
52
  "vue-tsc": "3.3.10"
53
53
  },
package/src/agent.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  COMPUTER_SCREENSHOTS_ROOT_ID,
74
74
  } from "./roots.js";
75
75
  import {
76
+ createComputerCaptureCadenceV1,
76
77
  fileComputerScreenshotV1,
77
78
  type ComputerProjectionFileInvalidationV1,
78
79
  type ComputerProjectionFileKindV1,
@@ -142,6 +143,11 @@ export interface ComputerAgentPluginConfig {
142
143
  };
143
144
  /** Drops resident projection caches after this Turn's Workspace sync. */
144
145
  projectionFiles?: ComputerProjectionFileInvalidationV1;
146
+ /**
147
+ * The shortest gap between two mid-Turn progress captures. Tests set it;
148
+ * production takes `COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS`.
149
+ */
150
+ progressCaptureIntervalMs?: number;
145
151
  }
146
152
 
147
153
  export const HUMAN_CONTROL_PROMPT_LINE =
@@ -564,6 +570,13 @@ export function createComputerAgentPlugin(
564
570
  // Agent loop knows it; a tool context does not, so it is caught where the
565
571
  // loop already announces it.
566
572
  let currentTurn = 1;
573
+ // One cadence per mounted plugin, which is one per Turn: a Turn's first
574
+ // Computer action always gets its capture, and the rest are debounced.
575
+ const progressCadence = createComputerCaptureCadenceV1(
576
+ config.progressCaptureIntervalMs === undefined
577
+ ? undefined
578
+ : { intervalMs: config.progressCaptureIntervalMs },
579
+ );
567
580
  const projectionWrites = new Set<ComputerProjectionFileKindV1>();
568
581
  const noteProjectionWrite = (kind: ComputerProjectionFileKindV1): void => {
569
582
  projectionWrites.add(kind);
@@ -676,6 +689,7 @@ export function createComputerAgentPlugin(
676
689
  },
677
690
  { signal: context.signal, effectId: context.effectId },
678
691
  );
692
+ await fileProgressCapture(computer, context.botId, context);
679
693
  return {
680
694
  content: [text(result.stdout), text(result.stderr)]
681
695
  .filter(Boolean)
@@ -1008,6 +1022,60 @@ export function createComputerAgentPlugin(
1008
1022
 
1009
1023
  let captureSequence = 0;
1010
1024
 
1025
+ /**
1026
+ * Files one capture of the desktop the Bot has just acted on, and tells
1027
+ * the browser to read again.
1028
+ *
1029
+ * "Live while working" has two halves, and this is the one that works
1030
+ * without a viewer session: the card that cannot open a stream still
1031
+ * shows a picture of what the Bot did a second ago rather than what it
1032
+ * did at the end of the last Turn. Debounced, best effort, and never the
1033
+ * reason a tool call fails — the Bot's answer is the tool's result, and a
1034
+ * photograph of the screen is a courtesy to the person watching.
1035
+ */
1036
+ const fileProgressCapture = async (
1037
+ computer: ComputerHandle,
1038
+ botId: string,
1039
+ context: ToolExecutionContext,
1040
+ ): Promise<void> => {
1041
+ if (!writer || !computer.workspace || !computer.screenshot) return;
1042
+ if (!progressCadence.admit(Date.now())) return;
1043
+ const botKey = computerBotPathKeyV1(botId);
1044
+ captureSequence += 1;
1045
+ try {
1046
+ await fileComputerScreenshotV1({
1047
+ computer,
1048
+ workspace: computer.workspace,
1049
+ path: {
1050
+ root: {
1051
+ kind: "package-declared",
1052
+ userId,
1053
+ packageId: "computer",
1054
+ rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
1055
+ },
1056
+ path: `${botKey}/${writer.turnId}-${captureSequence}.png`,
1057
+ },
1058
+ writer: {
1059
+ kind: "bot",
1060
+ botId,
1061
+ sessionId: writer.sessionId,
1062
+ turnId: writer.turnId,
1063
+ runId: writer.runId,
1064
+ },
1065
+ botKey,
1066
+ effectId: `${context.effectId}:progress-screenshot`,
1067
+ });
1068
+ } catch {
1069
+ // A desktop that refused a capture — human control, a Sprite that
1070
+ // paused, a Computer with no screen — changes nothing the Bot did.
1071
+ return;
1072
+ }
1073
+ noteProjectionWrite("screenshots");
1074
+ // Flushed now rather than at Turn end: a capture nobody is told about
1075
+ // is the delay this exists to remove.
1076
+ invalidateProjectionWrites(botId);
1077
+ };
1078
+
1011
1079
  /**
1012
1080
  * Captures the Bot's own desktop into the Package-declared `screenshots`
1013
1081
  * root.
@@ -1096,6 +1164,11 @@ export function createComputerAgentPlugin(
1096
1164
  effectId: context.effectId,
1097
1165
  });
1098
1166
  noteProjectionWrite("screenshots");
1167
+ // The Bot just looked at its own screen; so should the person
1168
+ // watching the card. Recording the admission keeps the very
1169
+ // next Computer action from filing a near-identical capture.
1170
+ progressCadence.admit(Date.now());
1171
+ invalidateProjectionWrites(context.botId);
1099
1172
  const dimensions = pngDimensionsV1(filed.captured.bytes);
1100
1173
  const attachment: ToolAttachmentV1 = {
1101
1174
  kind: "image",
@@ -1436,6 +1509,7 @@ export function createComputerAgentPlugin(
1436
1509
  signal: context.signal,
1437
1510
  effectId: context.effectId,
1438
1511
  });
1512
+ await fileProgressCapture(computer, context.botId, context);
1439
1513
  return {
1440
1514
  content: result.accessibilitySnapshot,
1441
1515
  isError: false,
@@ -1463,7 +1537,12 @@ export function createComputerAgentPlugin(
1463
1537
  // A Turn's first step is where the Turn's sync state begins; a Turn that
1464
1538
  // never touches the Computer never syncs and never wakes one.
1465
1539
  ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
1466
- if (turn !== currentTurn) projectionWrites.clear();
1540
+ if (turn !== currentTurn) {
1541
+ projectionWrites.clear();
1542
+ // Every Turn's first Computer action is worth a capture, however
1543
+ // soon after the previous Turn's last one it happens.
1544
+ progressCadence.reset();
1545
+ }
1467
1546
  currentTurn = turn;
1468
1547
  turnSync.beginTurn(turn);
1469
1548
  if (controlPrompt?.loadedTurn() !== turn) {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS,
4
+ createComputerCaptureCadenceV1,
5
+ } from "./capture.js";
6
+
7
+ describe("the mid-Turn capture cadence", () => {
8
+ test("captures the first Computer action of a Turn immediately", () => {
9
+ const cadence = createComputerCaptureCadenceV1();
10
+ expect(cadence.admit(1_000)).toBe(true);
11
+ });
12
+
13
+ test("files at most one capture per interval however busy the Turn", () => {
14
+ const cadence = createComputerCaptureCadenceV1({ intervalMs: 2_000 });
15
+ expect(cadence.admit(0)).toBe(true);
16
+ // Twenty browser actions inside two seconds cost one screenshot.
17
+ for (let at = 1; at < 2_000; at += 100) {
18
+ expect(cadence.admit(at)).toBe(false);
19
+ }
20
+ expect(cadence.admit(2_000)).toBe(true);
21
+ expect(cadence.admit(2_001)).toBe(false);
22
+ expect(cadence.admit(4_000)).toBe(true);
23
+ });
24
+
25
+ test("a new Turn starts capturing again after a reset", () => {
26
+ const cadence = createComputerCaptureCadenceV1({ intervalMs: 2_000 });
27
+ expect(cadence.admit(0)).toBe(true);
28
+ expect(cadence.admit(10)).toBe(false);
29
+ cadence.reset();
30
+ expect(cadence.admit(20)).toBe(true);
31
+ });
32
+
33
+ test("a clock that jumps backwards does not stop captures", () => {
34
+ const cadence = createComputerCaptureCadenceV1({ intervalMs: 2_000 });
35
+ expect(cadence.admit(10_000)).toBe(true);
36
+ expect(cadence.admit(1_000)).toBe(true);
37
+ });
38
+
39
+ test("defaults to roughly two seconds", () => {
40
+ expect(COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS).toBe(2_000);
41
+ const cadence = createComputerCaptureCadenceV1();
42
+ expect(cadence.admit(0)).toBe(true);
43
+ expect(cadence.admit(COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS - 1)).toBe(
44
+ false,
45
+ );
46
+ expect(cadence.admit(COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS)).toBe(true);
47
+ });
48
+ });
package/src/capture.ts CHANGED
@@ -14,6 +14,56 @@ import { COMPUTER_SCREENSHOT_RETENTION } from "./roots.js";
14
14
 
15
15
  export type ComputerProjectionFileKindV1 = "screenshots" | "doctor";
16
16
 
17
+ /**
18
+ * The shortest gap between two progress captures of the same desktop.
19
+ *
20
+ * A Turn can run a dozen Computer actions a second, and each capture crosses
21
+ * a service binding to the Sprite and writes durable bytes. Two seconds is
22
+ * fast enough that the card looks like it is following the Bot and slow
23
+ * enough that a busy Turn does not spend itself photographing a screen.
24
+ */
25
+ export const COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS = 2_000;
26
+
27
+ /**
28
+ * Decides whether the next Computer action gets a fresh capture filed for it.
29
+ *
30
+ * Turn-end captures do not ask: the last frame of a Turn is the one the card
31
+ * will show for as long as the Bot is idle, so it is always filed. Everything
32
+ * inside the Turn is progress, and progress is debounced.
33
+ */
34
+ export interface ComputerCaptureCadenceV1 {
35
+ /** True at most once per interval; records the admission when it grants. */
36
+ admit(now: number): boolean;
37
+ /** Forgets the last admission, so the next call grants immediately. */
38
+ reset(): void;
39
+ }
40
+
41
+ export function createComputerCaptureCadenceV1(options?: {
42
+ intervalMs?: number;
43
+ }): ComputerCaptureCadenceV1 {
44
+ const intervalMs =
45
+ options?.intervalMs ?? COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS;
46
+ let lastAdmittedAt: number | undefined;
47
+ return {
48
+ admit(now: number): boolean {
49
+ // A clock that went backwards is not a licence to stop capturing; the
50
+ // gap is measured forwards only.
51
+ if (
52
+ lastAdmittedAt !== undefined &&
53
+ now >= lastAdmittedAt &&
54
+ now - lastAdmittedAt < intervalMs
55
+ ) {
56
+ return false;
57
+ }
58
+ lastAdmittedAt = now;
59
+ return true;
60
+ },
61
+ reset(): void {
62
+ lastAdmittedAt = undefined;
63
+ },
64
+ };
65
+ }
66
+
17
67
  /** Invalidates files projected by one resident Bot Durable Object. */
18
68
  export interface ComputerProjectionFileInvalidationV1 {
19
69
  invalidate(botId: string, kind: ComputerProjectionFileKindV1): void;
@@ -1,7 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import { useRpc } from "@cordisjs/client";
3
3
  import { UiIcon } from "@frockbot/client-ui";
4
- import { computed, inject, ref } from "vue";
4
+ import {
5
+ computed,
6
+ inject,
7
+ onBeforeUnmount,
8
+ onMounted,
9
+ ref,
10
+ watch,
11
+ watchEffect,
12
+ } from "vue";
5
13
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
6
14
  import { computerKey, type ComputerState } from "../shared.ts";
7
15
  import { COMPUTER_COLD_PROVISION_EXPECTATION } from "../protocol.ts";
@@ -10,6 +18,12 @@ import {
10
18
  computerProgressFrame,
11
19
  computerProgressRunKind,
12
20
  } from "./progress.ts";
21
+ import {
22
+ COMPUTER_SCREEN_STATUS_TICK_MS,
23
+ computerScreenModeV1,
24
+ computerScreenStatusLabelV1,
25
+ } from "./live-preview.ts";
26
+ import { viewerUrlForControlV1 } from "./viewer.ts";
13
27
 
14
28
  const computer = inject(computerKey) ?? useRpc<ComputerState>();
15
29
  const state = computed(() => computer.value);
@@ -89,6 +103,92 @@ const progressAriaLabel = computed(
89
103
  () => `${openingHeading.value}: ${progressPhaseLabel.value}`,
90
104
  );
91
105
 
106
+ // ---------------------------------------------------------------------------
107
+ // Live while working.
108
+ //
109
+ // The card draws the Bot's own screen region as it changes, in the same
110
+ // view-only frame the full-screen viewer uses and on the same minted session
111
+ // — no second token, no takeover lease, and no input reaching the desktop.
112
+ // Rendering still wakes nothing: with no session minted the card stays on the
113
+ // stored capture, which the Bot now files after every Computer action.
114
+ // ---------------------------------------------------------------------------
115
+ const screen = ref<HTMLElement>();
116
+ const onScreen = ref(true);
117
+ const documentVisible = ref(
118
+ typeof document === "undefined" || document.visibilityState === "visible",
119
+ );
120
+ const now = ref(Date.now());
121
+ let statusTicker: ReturnType<typeof setInterval> | undefined;
122
+ let observer: IntersectionObserver | undefined;
123
+ /** When the Bot's last Turn stopped; the grace window is measured from it. */
124
+ const turnEndedAt = ref<number | undefined>(undefined);
125
+ const turnRunning = computed(() => Boolean(shell?.value.runningRunId));
126
+ const screenMode = computed(() =>
127
+ computerScreenModeV1({
128
+ ...(state.value.viewerUrl ? { viewerUrl: state.value.viewerUrl } : {}),
129
+ phase: state.value.phase,
130
+ expanded: state.value.expanded,
131
+ turnRunning: turnRunning.value,
132
+ onScreen: onScreen.value,
133
+ documentVisible: documentVisible.value,
134
+ ...(turnEndedAt.value === undefined
135
+ ? {}
136
+ : { sinceTurnEndedMs: now.value - turnEndedAt.value }),
137
+ }),
138
+ );
139
+ const streaming = computed(() => screenMode.value === "stream");
140
+ // The one client-visible input fence, set the same way the overlay sets it.
141
+ // The card never asks for control, so this URL is always the view-only one.
142
+ const previewSrc = computed(() =>
143
+ streaming.value && state.value.viewerUrl
144
+ ? viewerUrlForControlV1(state.value.viewerUrl, false)
145
+ : undefined,
146
+ );
147
+ const screenStatus = computed(() =>
148
+ computerScreenStatusLabelV1({
149
+ mode: screenMode.value,
150
+ ...(screenshot.value ? { capturedAt: screenshot.value.capturedAt } : {}),
151
+ now: now.value,
152
+ }),
153
+ );
154
+
155
+ watch(turnRunning, (running, previous) => {
156
+ if (previous && !running) turnEndedAt.value = Date.now();
157
+ if (running) turnEndedAt.value = undefined;
158
+ });
159
+ // Holding is what keeps the minted session alive while the card watches it;
160
+ // releasing is what stops an idle Bot from paying for a stream nobody reads.
161
+ watchEffect(() => {
162
+ state.value.holdLivePreview?.(streaming.value);
163
+ });
164
+
165
+ function readVisibility(): void {
166
+ documentVisible.value =
167
+ typeof document === "undefined" || document.visibilityState === "visible";
168
+ }
169
+
170
+ onMounted(() => {
171
+ document.addEventListener("visibilitychange", readVisibility);
172
+ statusTicker = setInterval(() => {
173
+ now.value = Date.now();
174
+ }, COMPUTER_SCREEN_STATUS_TICK_MS);
175
+ if (typeof IntersectionObserver === "undefined" || !screen.value) return;
176
+ observer = new IntersectionObserver(
177
+ (entries) => {
178
+ const entry = entries.at(-1);
179
+ if (entry) onScreen.value = entry.isIntersecting;
180
+ },
181
+ { threshold: 0.05 },
182
+ );
183
+ observer.observe(screen.value);
184
+ });
185
+ onBeforeUnmount(() => {
186
+ document.removeEventListener("visibilitychange", readVisibility);
187
+ if (statusTicker !== undefined) clearInterval(statusTicker);
188
+ observer?.disconnect();
189
+ state.value.holdLivePreview?.(false);
190
+ });
191
+
92
192
  async function open(): Promise<void> {
93
193
  if (busy.value) return;
94
194
  busy.value = true;
@@ -105,14 +205,26 @@ async function open(): Promise<void> {
105
205
  <template>
106
206
  <section v-if="hasBot" class="computer-card">
107
207
  <button
208
+ ref="screen"
108
209
  type="button"
109
210
  class="computer-screen computer-screen-thumbnail"
211
+ :class="{ 'is-live': streaming }"
110
212
  :disabled="busy || unconfigured"
111
213
  aria-label="Open computer in full window"
112
214
  @click="open"
113
215
  >
216
+ <iframe
217
+ v-if="previewSrc && !opening"
218
+ class="computer-screen-preview"
219
+ :src="previewSrc"
220
+ title="Computer, live"
221
+ tabindex="-1"
222
+ aria-hidden="true"
223
+ sandbox="allow-same-origin allow-scripts"
224
+ referrerpolicy="no-referrer"
225
+ />
114
226
  <img
115
- v-if="screenshot && !opening"
227
+ v-else-if="screenshot && !opening"
116
228
  :key="screenshot.contentHash"
117
229
  :src="screenshot.url"
118
230
  alt=""
@@ -157,5 +269,14 @@ async function open(): Promise<void> {
157
269
  </template>
158
270
  </span>
159
271
  </button>
272
+ <p
273
+ v-if="screenStatus"
274
+ class="computer-screen-status"
275
+ :class="{ 'is-live': streaming }"
276
+ aria-live="polite"
277
+ >
278
+ <span class="computer-screen-status-dot" aria-hidden="true" />
279
+ {{ screenStatus }}
280
+ </p>
160
281
  </section>
161
282
  </template>
@@ -345,6 +345,34 @@ describe("hosted Computer provider", () => {
345
345
  mounted.dispose();
346
346
  });
347
347
 
348
+ test("a held card preview keeps the same session alive, and a hidden tab drops it", async () => {
349
+ const mounted = mountHostedProvider();
350
+ await flush();
351
+ await mounted.state.connect();
352
+ await flush();
353
+ // A minted session that nobody is watching is not renewed.
354
+ expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
355
+
356
+ mounted.state.holdLivePreview?.(true);
357
+ expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(1);
358
+ mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
359
+ await flush();
360
+ expect(postedTypes(mounted.calls)).toEqual(["connect", "refreshViewer"]);
361
+ // The card never expands and never asks for control to watch a Bot work.
362
+ expect(mounted.state.expanded).toBe(false);
363
+ expect(mounted.state.takingControl).toBe(false);
364
+
365
+ mounted.runtime.setVisible(false);
366
+ expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
367
+ mounted.runtime.setVisible(true);
368
+ await flush();
369
+ expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(1);
370
+
371
+ mounted.state.holdLivePreview?.(false);
372
+ expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
373
+ mounted.dispose();
374
+ });
375
+
348
376
  test("an updating card click rejoins the update and lands on ready when it finishes", async () => {
349
377
  const mounted = mountHostedProvider();
350
378
  await flush();
@@ -95,11 +95,19 @@ export function createComputerClientPlugin(
95
95
  let controlRequest: Promise<void> | undefined;
96
96
  /** Set by a release the backend refused; no heartbeat renews after it. */
97
97
  let controlAbandoned = false;
98
+ /**
99
+ * Whether a view-only card preview is watching the desktop right now.
100
+ *
101
+ * It is the card's declaration, not a second session: the heartbeat it
102
+ * keeps alive renews the same minted viewer the overlay would use.
103
+ */
104
+ let livePreviewHeld = false;
98
105
 
99
106
  const state = ref<ComputerState>({
100
107
  ...machine,
101
108
  connect: () => connect("connect-requested"),
102
109
  openViewer: () => openViewer(),
110
+ holdLivePreview: (held: boolean) => holdLivePreview(held),
103
111
  closeViewer: () => closeViewer(),
104
112
  takeControl: () => takeControl(),
105
113
  releaseControl: () => releaseControl(),
@@ -148,7 +156,11 @@ export function createComputerClientPlugin(
148
156
  }
149
157
 
150
158
  function syncViewerHeartbeat(): void {
151
- if (!machine.expanded || !machine.viewerUrl) {
159
+ // A hidden tab watches nothing, so it renews nothing: the slot lapses
160
+ // and the Sprite stops paying for a stream no one is looking at.
161
+ const watching =
162
+ machine.expanded || (livePreviewHeld && runtime.isVisible());
163
+ if (!watching || !machine.viewerUrl) {
152
164
  stopViewerHeartbeat();
153
165
  return;
154
166
  }
@@ -333,6 +345,12 @@ export function createComputerClientPlugin(
333
345
  await connect("connect-requested");
334
346
  }
335
347
 
348
+ function holdLivePreview(held: boolean): void {
349
+ if (livePreviewHeld === held) return;
350
+ livePreviewHeld = held;
351
+ syncViewerHeartbeat();
352
+ }
353
+
336
354
  async function closeViewer(): Promise<void> {
337
355
  if (!machine.expanded) return;
338
356
  // A host with no Computer never had a viewer session to close.
@@ -431,6 +449,9 @@ export function createComputerClientPlugin(
431
449
  stopViewerHeartbeat();
432
450
  stopUpdateRejoin();
433
451
  controlAbandoned = false;
452
+ // The card remounts its preview against the newly selected Bot; a
453
+ // hold carried across would renew the previous Bot's session.
454
+ livePreviewHeld = false;
434
455
  machine = initialComputerMachineState();
435
456
  Object.assign(state.value, machine);
436
457
  watchStateChannel(selectedBotId);
@@ -443,6 +464,7 @@ export function createComputerClientPlugin(
443
464
  );
444
465
  const stopVisibility = runtime.onVisibilityChange(() => {
445
466
  syncProjectionPoll();
467
+ syncViewerHeartbeat();
446
468
  syncUpdateRejoin();
447
469
  const selectedBotId = shell.value.activeBotId;
448
470
  if (!selectedBotId || !runtime.isVisible()) return;
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import {
4
+ COMPUTER_LIVE_PREVIEW_GRACE_MS,
5
+ computerScreenModeV1,
6
+ computerScreenStatusLabelV1,
7
+ computerSnapshotAgeLabelV1,
8
+ type ComputerScreenModeInputV1,
9
+ } from "./live-preview.js";
10
+
11
+ const cardSource = readFileSync(
12
+ new URL("./ComputerCard.vue", import.meta.url),
13
+ "utf8",
14
+ );
15
+
16
+ const watching: ComputerScreenModeInputV1 = {
17
+ viewerUrl: "https://sprite.invalid/vnc.html#view_only=1&path=websockify",
18
+ phase: "ready",
19
+ expanded: false,
20
+ turnRunning: true,
21
+ onScreen: true,
22
+ documentVisible: true,
23
+ };
24
+
25
+ describe("the card's live-vs-snapshot choice", () => {
26
+ test("streams the desktop while the Bot's Turn is running", () => {
27
+ expect(computerScreenModeV1(watching)).toBe("stream");
28
+ });
29
+
30
+ test("stays on the stored capture when no session has been minted", () => {
31
+ expect(computerScreenModeV1({ ...watching, viewerUrl: undefined })).toBe(
32
+ "snapshot",
33
+ );
34
+ });
35
+
36
+ test("a hidden tab holds no stream", () => {
37
+ expect(computerScreenModeV1({ ...watching, documentVisible: false })).toBe(
38
+ "snapshot",
39
+ );
40
+ });
41
+
42
+ test("a card scrolled off screen holds no stream", () => {
43
+ expect(computerScreenModeV1({ ...watching, onScreen: false })).toBe(
44
+ "snapshot",
45
+ );
46
+ });
47
+
48
+ test("the open full-screen viewer keeps the session warm off screen", () => {
49
+ expect(
50
+ computerScreenModeV1({
51
+ ...watching,
52
+ onScreen: false,
53
+ turnRunning: false,
54
+ expanded: true,
55
+ }),
56
+ ).toBe("stream");
57
+ });
58
+
59
+ test("an idle Bot with no Turn ever run shows the snapshot", () => {
60
+ expect(computerScreenModeV1({ ...watching, turnRunning: false })).toBe(
61
+ "snapshot",
62
+ );
63
+ });
64
+
65
+ test("a settled Turn keeps streaming through the grace window only", () => {
66
+ const settled = { ...watching, turnRunning: false };
67
+ expect(computerScreenModeV1({ ...settled, sinceTurnEndedMs: 1_000 })).toBe(
68
+ "stream",
69
+ );
70
+ expect(
71
+ computerScreenModeV1({
72
+ ...settled,
73
+ sinceTurnEndedMs: COMPUTER_LIVE_PREVIEW_GRACE_MS,
74
+ }),
75
+ ).toBe("snapshot");
76
+ expect(
77
+ computerScreenModeV1({
78
+ ...settled,
79
+ sinceTurnEndedMs: 1_000,
80
+ graceMs: 500,
81
+ }),
82
+ ).toBe("snapshot");
83
+ });
84
+
85
+ test("a host mid-operation draws its progress, not a frame", () => {
86
+ for (const phase of [
87
+ "provisioning",
88
+ "updating",
89
+ "disconnected",
90
+ "error",
91
+ "idle",
92
+ "unconfigured",
93
+ ] as const) {
94
+ expect(computerScreenModeV1({ ...watching, phase })).toBe("snapshot");
95
+ }
96
+ expect(computerScreenModeV1({ ...watching, phase: "human-control" })).toBe(
97
+ "stream",
98
+ );
99
+ });
100
+ });
101
+
102
+ describe("the status line under the screen", () => {
103
+ test("says Live while the stream is up", () => {
104
+ expect(computerScreenStatusLabelV1({ mode: "stream", now: 1_000 })).toBe(
105
+ "Live",
106
+ );
107
+ });
108
+
109
+ test("ages the snapshot in the User's units", () => {
110
+ const now = Date.parse("2026-09-04T00:01:00.000Z");
111
+ expect(
112
+ computerScreenStatusLabelV1({
113
+ mode: "snapshot",
114
+ capturedAt: "2026-09-04T00:00:48.000Z",
115
+ now,
116
+ }),
117
+ ).toBe("Snapshot · 12s ago");
118
+ expect(computerSnapshotAgeLabelV1(90_000)).toBe("1m ago");
119
+ expect(computerSnapshotAgeLabelV1(3 * 3_600_000)).toBe("3h ago");
120
+ expect(computerSnapshotAgeLabelV1(50 * 3_600_000)).toBe("2d ago");
121
+ expect(computerSnapshotAgeLabelV1(-5_000)).toBe("0s ago");
122
+ });
123
+
124
+ test("says nothing at all when there is no capture to age", () => {
125
+ expect(
126
+ computerScreenStatusLabelV1({ mode: "snapshot", now: 1_000 }),
127
+ ).toBeUndefined();
128
+ expect(
129
+ computerScreenStatusLabelV1({
130
+ mode: "snapshot",
131
+ capturedAt: "not a date",
132
+ now: 1_000,
133
+ }),
134
+ ).toBeUndefined();
135
+ });
136
+
137
+ test("uses no architecture words", () => {
138
+ for (const word of ["VNC", "noVNC", "iframe", "websocket", "session"]) {
139
+ expect(
140
+ computerScreenStatusLabelV1({
141
+ mode: "snapshot",
142
+ capturedAt: "2026-09-04T00:00:00.000Z",
143
+ now: Date.parse("2026-09-04T00:00:05.000Z"),
144
+ }),
145
+ ).not.toContain(word);
146
+ }
147
+ });
148
+ });
149
+
150
+ describe("the card's live frame", () => {
151
+ test("is view-only and takes no input", () => {
152
+ // The card never asks for control: the second argument is the input fence
153
+ // and it is hard-coded false, so the framed viewer is minted view-only.
154
+ expect(cardSource).toContain(
155
+ "viewerUrlForControlV1(state.value.viewerUrl, false)",
156
+ );
157
+ expect(cardSource).not.toContain("allow-pointer-lock");
158
+ expect(cardSource).toContain('tabindex="-1"');
159
+ expect(cardSource).toContain('aria-hidden="true"');
160
+ });
161
+
162
+ test("keeps click-to-take-control on the button behind it", () => {
163
+ expect(cardSource).toContain('aria-label="Open computer in full window"');
164
+ expect(cardSource).toContain('@click="open"');
165
+ });
166
+
167
+ test("releases the held session when the card leaves the screen", () => {
168
+ expect(cardSource).toContain("holdLivePreview?.(streaming.value)");
169
+ expect(cardSource).toContain("holdLivePreview?.(false)");
170
+ expect(cardSource).toContain("visibilitychange");
171
+ expect(cardSource).toContain("IntersectionObserver");
172
+ });
173
+ });
@@ -0,0 +1,98 @@
1
+ import type { ComputerPhase } from "../protocol.js";
2
+
3
+ /**
4
+ * How long the card keeps streaming after the Bot's Turn settles.
5
+ *
6
+ * A Turn that ends is usually followed by another within seconds — a tool
7
+ * result the model answers, or a User reply. Dropping the VNC connection the
8
+ * instant a Turn stops would make the next one reconnect from black, which is
9
+ * the stall this feature exists to remove. The grace window is what stops an
10
+ * idle Bot from holding a connection all afternoon.
11
+ */
12
+ export const COMPUTER_LIVE_PREVIEW_GRACE_MS = 15_000;
13
+
14
+ /** How often the card re-reads its own status line while it is on screen. */
15
+ export const COMPUTER_SCREEN_STATUS_TICK_MS = 1_000;
16
+
17
+ /**
18
+ * The phases in which a minted viewer session addresses a desktop that is
19
+ * actually there. Provisioning and updating are hosts mid-operation, and the
20
+ * card draws their progress rather than a frame that cannot connect.
21
+ */
22
+ const STREAMABLE_PHASES: readonly ComputerPhase[] = [
23
+ "ready",
24
+ "taking-control",
25
+ "human-control",
26
+ ];
27
+
28
+ export type ComputerScreenModeV1 = "stream" | "snapshot";
29
+
30
+ export interface ComputerScreenModeInputV1 {
31
+ /** The minted view-only viewer URL, absent until a session exists. */
32
+ viewerUrl?: string;
33
+ phase: ComputerPhase;
34
+ /** The full-screen viewer is open over the shell. */
35
+ expanded: boolean;
36
+ /** A Turn is executing for the selected Bot right now. */
37
+ turnRunning: boolean;
38
+ /** The card is mounted and inside the viewport. */
39
+ onScreen: boolean;
40
+ /** `document.visibilityState === "visible"`. */
41
+ documentVisible: boolean;
42
+ /**
43
+ * Milliseconds since the Bot's last Turn stopped running. Undefined where
44
+ * no Turn has run in this session, which is not a reason to stream.
45
+ */
46
+ sinceTurnEndedMs?: number;
47
+ graceMs?: number;
48
+ }
49
+
50
+ /**
51
+ * Whether the card draws the Bot's desktop live or the last stored capture.
52
+ *
53
+ * The rule is one sentence: stream a desktop that exists, to a card someone
54
+ * is actually looking at, while the Bot is working or has just stopped. Every
55
+ * other answer is the snapshot, which costs nothing to hold.
56
+ */
57
+ export function computerScreenModeV1(
58
+ input: ComputerScreenModeInputV1,
59
+ ): ComputerScreenModeV1 {
60
+ if (!input.viewerUrl) return "snapshot";
61
+ if (!input.documentVisible) return "snapshot";
62
+ if (!input.onScreen && !input.expanded) return "snapshot";
63
+ if (!STREAMABLE_PHASES.includes(input.phase)) return "snapshot";
64
+ if (input.expanded || input.turnRunning) return "stream";
65
+ const grace = input.graceMs ?? COMPUTER_LIVE_PREVIEW_GRACE_MS;
66
+ const since = input.sinceTurnEndedMs;
67
+ return since !== undefined && since < grace ? "stream" : "snapshot";
68
+ }
69
+
70
+ /** A whole-unit age, coarse enough that it does not redraw every frame. */
71
+ export function computerSnapshotAgeLabelV1(ageMs: number): string {
72
+ const seconds = Math.max(0, Math.floor(ageMs / 1_000));
73
+ if (seconds < 60) return `${seconds}s ago`;
74
+ const minutes = Math.floor(seconds / 60);
75
+ if (minutes < 60) return `${minutes}m ago`;
76
+ const hours = Math.floor(minutes / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ return `${Math.floor(hours / 24)}d ago`;
79
+ }
80
+
81
+ /**
82
+ * The one line under the screen, in the User's words.
83
+ *
84
+ * "Live" and "Snapshot · 12s ago" are the whole vocabulary: the User asked
85
+ * whether they are watching the Bot or a photograph of it, and no answer that
86
+ * names a transport or a session answers that question.
87
+ */
88
+ export function computerScreenStatusLabelV1(input: {
89
+ mode: ComputerScreenModeV1;
90
+ capturedAt?: string;
91
+ now: number;
92
+ }): string | undefined {
93
+ if (input.mode === "stream") return "Live";
94
+ if (!input.capturedAt) return undefined;
95
+ const capturedAt = Date.parse(input.capturedAt);
96
+ if (!Number.isFinite(capturedAt)) return undefined;
97
+ return `Snapshot · ${computerSnapshotAgeLabelV1(input.now - capturedAt)}`;
98
+ }
@@ -97,6 +97,63 @@ button.computer-screen {
97
97
  pointer-events: auto;
98
98
  }
99
99
 
100
+ /*
101
+ * The card's live frame. It is inert by inheritance: `.computer-screen iframe`
102
+ * already takes no pointer events, so a click lands on the button behind it and
103
+ * opens the full-screen viewer exactly as a click on the stored capture did.
104
+ */
105
+ .computer-screen-preview {
106
+ display: block;
107
+ }
108
+
109
+ .computer-screen-thumbnail.is-live {
110
+ border-color: var(--frock-computer-accent);
111
+ }
112
+
113
+ .computer-screen-status {
114
+ display: flex;
115
+ gap: 6px;
116
+ align-items: center;
117
+ margin: 6px 0 0;
118
+ color: var(--frock-text-muted);
119
+ font-size: var(--frock-text-xs);
120
+ line-height: 1.4;
121
+ }
122
+
123
+ .computer-screen-status-dot {
124
+ width: 6px;
125
+ height: 6px;
126
+ border-radius: 50%;
127
+ background: currentColor;
128
+ opacity: 0.5;
129
+ }
130
+
131
+ .computer-screen-status.is-live {
132
+ color: var(--frock-computer-accent-text);
133
+ }
134
+
135
+ .computer-screen-status.is-live .computer-screen-status-dot {
136
+ background: var(--frock-computer-accent);
137
+ opacity: 1;
138
+ animation: computer-live-pulse 2s ease-in-out infinite;
139
+ }
140
+
141
+ @keyframes computer-live-pulse {
142
+ 0%,
143
+ 100% {
144
+ opacity: 1;
145
+ }
146
+ 50% {
147
+ opacity: 0.35;
148
+ }
149
+ }
150
+
151
+ @media (prefers-reduced-motion: reduce) {
152
+ .computer-screen-status.is-live .computer-screen-status-dot {
153
+ animation: none;
154
+ }
155
+ }
156
+
100
157
  .human-control .computer-screen {
101
158
  border-color: var(--frock-computer-accent);
102
159
  box-shadow: 0 0 0 1px var(--frock-computer-focus);
@@ -294,7 +294,9 @@ describe("computer_screenshot", () => {
294
294
 
295
295
  await harness.root.serial("agent/turn-stopping", agent as never, 1);
296
296
 
297
- expect(workspace.writes).toHaveLength(1);
297
+ // One capture for the action the Bot just took, so the card can show it
298
+ // working, and one final frame at Turn end.
299
+ expect(workspace.writes).toHaveLength(2);
298
300
  expect(workspace.writes[0]?.writer).toEqual({
299
301
  kind: "bot",
300
302
  botId: "bot-1",
@@ -302,7 +304,41 @@ describe("computer_screenshot", () => {
302
304
  turnId: "run-9",
303
305
  runId: "run-9",
304
306
  });
305
- expect(invalidations).toEqual(["bot-1:screenshots"]);
307
+ // The mid-Turn capture is announced as soon as it is filed; waiting for
308
+ // Turn end is the delay the live card exists to remove.
309
+ expect(invalidations).toEqual(["bot-1:screenshots", "bot-1:screenshots"]);
310
+ await harness.dispose();
311
+ });
312
+
313
+ test("a busy Turn files at most one progress capture per interval", async () => {
314
+ const workspace = new FakeWorkspace();
315
+ const harness = await mount(
316
+ providerWith(workspace, () =>
317
+ Promise.resolve({
318
+ bytes: png(1280, 720),
319
+ mediaType: "image/png" as const,
320
+ display: ":100",
321
+ capturedAt: "2026-09-03T00:00:10.000Z",
322
+ }),
323
+ ),
324
+ true,
325
+ );
326
+ const session = harness.root.sessions.create("session-1");
327
+ const agent = { botId: "bot-1", session };
328
+ await harness.root.waterfall(
329
+ "agent/pre-step",
330
+ agent as never,
331
+ [],
332
+ 1,
333
+ 1,
334
+ () => Promise.resolve({ kind: "enter" as const, inputs: [] }),
335
+ );
336
+ for (let call = 0; call < 5; call += 1) {
337
+ await executeTool(harness, "computer_exec", { command: "pwd" });
338
+ }
339
+
340
+ // Five shell commands inside the debounce window, one photograph.
341
+ expect(workspace.writes).toHaveLength(1);
306
342
  await harness.dispose();
307
343
  });
308
344
 
@@ -332,7 +368,9 @@ describe("computer_screenshot", () => {
332
368
  await expect(
333
369
  harness.root.serial("agent/turn-stopping", agent as never, 1),
334
370
  ).resolves.toBeUndefined();
335
- expect(captures).toBe(1);
371
+ // Both the progress capture and the final frame are refused, and neither
372
+ // refusal reaches the Bot's answer or the Turn's outcome.
373
+ expect(captures).toBe(2);
336
374
  expect(workspace.writes).toHaveLength(0);
337
375
  await harness.dispose();
338
376
  });
package/src/shared.ts CHANGED
@@ -45,6 +45,17 @@ export interface ComputerState {
45
45
  connect(): Promise<void>;
46
46
  /** Explicit User open. An idle Computer may wake; rendering never does. */
47
47
  openViewer(): Promise<void>;
48
+ /**
49
+ * Declares that a view-only card preview is on screen.
50
+ *
51
+ * Held, the one minted viewer session is kept alive by the same heartbeat
52
+ * the full-screen viewer uses, so a card watching a working Bot does not
53
+ * lose the desktop to session expiry. Releasing it lets the session lapse,
54
+ * which is what stops an idle Bot from holding a VNC connection. It mints
55
+ * nothing and wakes nothing: a Computer with no session stays on the
56
+ * stored capture (P1).
57
+ */
58
+ holdLivePreview?(held: boolean): void;
48
59
  /** Closes the viewer, releasing human control before it disappears. */
49
60
  closeViewer(): Promise<void>;
50
61
  takeControl(): Promise<void>;
package/tsconfig.json CHANGED
@@ -5,9 +5,8 @@
5
5
  "moduleResolution": "Bundler",
6
6
  "allowImportingTsExtensions": true,
7
7
  "resolveJsonModule": true,
8
- "baseUrl": ".",
9
8
  "paths": {
10
- "@cordisjs/client": ["src/client/cordis-client-shim.d.ts"]
9
+ "@cordisjs/client": ["./src/client/cordis-client-shim.d.ts"]
11
10
  },
12
11
  "strict": true,
13
12
  "noEmit": true,