@moxxy/plugin-computer-control 0.41.2 → 0.42.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.
Files changed (269) hide show
  1. package/bin/win32-x64/moxxy-computer.exe +0 -0
  2. package/bin/win32-x64/moxxy-computer.exe.json +1 -1
  3. package/dist/backend/access.d.ts +129 -0
  4. package/dist/backend/access.d.ts.map +1 -0
  5. package/dist/backend/access.js +158 -0
  6. package/dist/backend/access.js.map +1 -0
  7. package/dist/backend/app-hints.d.ts +14 -0
  8. package/dist/backend/app-hints.d.ts.map +1 -0
  9. package/dist/backend/app-hints.js +30 -0
  10. package/dist/backend/app-hints.js.map +1 -0
  11. package/dist/backend/backend.d.ts +70 -0
  12. package/dist/backend/backend.d.ts.map +1 -0
  13. package/dist/backend/backend.js +466 -0
  14. package/dist/backend/backend.js.map +1 -0
  15. package/dist/backend/rpc.d.ts +1438 -0
  16. package/dist/backend/rpc.d.ts.map +1 -0
  17. package/dist/backend/rpc.js +75 -0
  18. package/dist/backend/rpc.js.map +1 -0
  19. package/dist/backend/turn-controls.d.ts +20 -0
  20. package/dist/backend/turn-controls.d.ts.map +1 -0
  21. package/dist/backend/turn-controls.js +115 -0
  22. package/dist/backend/turn-controls.js.map +1 -0
  23. package/dist/contract/guidance.d.ts +12 -0
  24. package/dist/contract/guidance.d.ts.map +1 -0
  25. package/dist/contract/guidance.js +42 -0
  26. package/dist/contract/guidance.js.map +1 -0
  27. package/dist/contract/image.d.ts +29 -0
  28. package/dist/contract/image.d.ts.map +1 -0
  29. package/dist/contract/image.js +46 -0
  30. package/dist/contract/image.js.map +1 -0
  31. package/dist/contract/keys.d.ts +15 -0
  32. package/dist/contract/keys.d.ts.map +1 -0
  33. package/dist/contract/keys.js +118 -0
  34. package/dist/contract/keys.js.map +1 -0
  35. package/dist/contract/outcome.d.ts +61 -0
  36. package/dist/contract/outcome.d.ts.map +1 -0
  37. package/dist/contract/outcome.js +63 -0
  38. package/dist/contract/outcome.js.map +1 -0
  39. package/dist/contract/progress.d.ts +27 -0
  40. package/dist/contract/progress.d.ts.map +1 -0
  41. package/dist/contract/progress.js +36 -0
  42. package/dist/contract/progress.js.map +1 -0
  43. package/dist/contract/tools.d.ts +338 -0
  44. package/dist/contract/tools.d.ts.map +1 -0
  45. package/dist/contract/tools.js +195 -0
  46. package/dist/contract/tools.js.map +1 -0
  47. package/dist/contract/untrusted.d.ts +3 -0
  48. package/dist/contract/untrusted.d.ts.map +1 -0
  49. package/dist/contract/untrusted.js +8 -0
  50. package/dist/contract/untrusted.js.map +1 -0
  51. package/dist/helper/artifact.d.ts +41 -0
  52. package/dist/helper/artifact.d.ts.map +1 -0
  53. package/dist/helper/artifact.js +189 -0
  54. package/dist/helper/artifact.js.map +1 -0
  55. package/dist/helper/protocol.d.ts +80 -0
  56. package/dist/helper/protocol.d.ts.map +1 -0
  57. package/dist/helper/protocol.js +49 -0
  58. package/dist/helper/protocol.js.map +1 -0
  59. package/dist/helper/transport.d.ts +43 -0
  60. package/dist/helper/transport.d.ts.map +1 -0
  61. package/dist/{windows → helper}/transport.js +45 -19
  62. package/dist/helper/transport.js.map +1 -0
  63. package/dist/index.d.ts +9 -22
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +35 -42
  66. package/dist/index.js.map +1 -1
  67. package/dist/jev/ladder.d.ts +32 -0
  68. package/dist/jev/ladder.d.ts.map +1 -0
  69. package/dist/jev/ladder.js +66 -0
  70. package/dist/jev/ladder.js.map +1 -0
  71. package/dist/jev/memory.d.ts +11 -0
  72. package/dist/jev/memory.d.ts.map +1 -0
  73. package/dist/jev/memory.js +16 -0
  74. package/dist/jev/memory.js.map +1 -0
  75. package/dist/jev/run.d.ts +93 -0
  76. package/dist/jev/run.d.ts.map +1 -0
  77. package/dist/jev/run.js +403 -0
  78. package/dist/jev/run.js.map +1 -0
  79. package/dist/jev/train.d.ts +97 -0
  80. package/dist/jev/train.d.ts.map +1 -0
  81. package/dist/jev/train.js +28 -0
  82. package/dist/jev/train.js.map +1 -0
  83. package/dist/linux/profile.d.ts +6 -0
  84. package/dist/linux/profile.d.ts.map +1 -0
  85. package/dist/linux/profile.js +21 -0
  86. package/dist/linux/profile.js.map +1 -0
  87. package/dist/macos/profile.d.ts +5 -0
  88. package/dist/macos/profile.d.ts.map +1 -0
  89. package/dist/macos/profile.js +17 -0
  90. package/dist/macos/profile.js.map +1 -0
  91. package/dist/preview/controller.d.ts +127 -0
  92. package/dist/preview/controller.d.ts.map +1 -0
  93. package/dist/preview/controller.js +203 -0
  94. package/dist/preview/controller.js.map +1 -0
  95. package/dist/preview/surface.d.ts +9 -0
  96. package/dist/preview/surface.d.ts.map +1 -0
  97. package/dist/preview/surface.js +50 -0
  98. package/dist/preview/surface.js.map +1 -0
  99. package/dist/windows/maintenance.d.ts +1 -1
  100. package/dist/windows/maintenance.d.ts.map +1 -1
  101. package/dist/windows/maintenance.js +6 -5
  102. package/dist/windows/maintenance.js.map +1 -1
  103. package/dist/windows/profile.d.ts +5 -0
  104. package/dist/windows/profile.d.ts.map +1 -0
  105. package/dist/windows/profile.js +17 -0
  106. package/dist/windows/profile.js.map +1 -0
  107. package/learned/README.md +26 -0
  108. package/learned/cases/com.apple.calculator.json +395 -0
  109. package/learned/cases/com.apple.finder.json +115 -0
  110. package/learned/cases/com.apple.safari.json +112 -0
  111. package/learned/cases/com.apple.systempreferences.json +255 -0
  112. package/learned/com.apple.calculator-eace95fc.json +334 -0
  113. package/learned/com.apple.finder-27cf6ce8.json +260 -0
  114. package/learned/com.apple.safari-7cd9df4f.json +143 -0
  115. package/learned/com.apple.systempreferences-02cf0b8b.json +535 -0
  116. package/package.json +13 -7
  117. package/scripts/promote-learned.mjs +8 -0
  118. package/scripts/summarize-trial.mjs +68 -0
  119. package/scripts/train-learned.mjs +64 -0
  120. package/skills/computer-apps/blender.md +29 -0
  121. package/skills/computer-apps/browsers.md +50 -0
  122. package/skills/computer-apps/design-tools.md +37 -0
  123. package/skills/computer-apps/finder.md +23 -0
  124. package/skills/computer-apps/office.md +46 -0
  125. package/skills/computer-apps/video-editors.md +44 -0
  126. package/skills/computer-control.md +154 -191
  127. package/src/backend/access.test.ts +158 -0
  128. package/src/backend/access.ts +168 -0
  129. package/src/backend/app-hints.test.ts +59 -0
  130. package/src/backend/app-hints.ts +39 -0
  131. package/src/backend/backend.test.ts +725 -0
  132. package/src/backend/backend.ts +482 -0
  133. package/src/backend/contract-helper.fixture.mjs +138 -0
  134. package/src/backend/helper.fixture.ts +38 -0
  135. package/src/backend/rpc.ts +90 -0
  136. package/src/backend/turn-controls.test.ts +178 -0
  137. package/src/backend/turn-controls.ts +120 -0
  138. package/src/contract/guidance.test.ts +75 -0
  139. package/src/contract/guidance.ts +48 -0
  140. package/src/contract/image.test.ts +74 -0
  141. package/src/contract/image.ts +54 -0
  142. package/src/contract/keys.test.ts +103 -0
  143. package/src/contract/keys.ts +117 -0
  144. package/src/contract/outcome.test.ts +71 -0
  145. package/src/contract/outcome.ts +72 -0
  146. package/src/contract/progress.test.ts +56 -0
  147. package/src/contract/progress.ts +43 -0
  148. package/src/contract/tools.test.ts +210 -0
  149. package/src/contract/tools.ts +212 -0
  150. package/src/contract/untrusted.test.ts +20 -0
  151. package/src/contract/untrusted.ts +8 -0
  152. package/src/helper/artifact.test.ts +131 -0
  153. package/src/helper/artifact.ts +182 -0
  154. package/src/helper/protocol.test.ts +29 -0
  155. package/src/helper/protocol.ts +50 -0
  156. package/src/{windows → helper}/transport.test.ts +76 -19
  157. package/src/{windows → helper}/transport.ts +54 -16
  158. package/src/index.test.ts +112 -0
  159. package/src/index.ts +36 -53
  160. package/src/jev/ladder.test.ts +113 -0
  161. package/src/jev/ladder.ts +88 -0
  162. package/src/jev/memory.ts +20 -0
  163. package/src/jev/run.test.ts +700 -0
  164. package/src/jev/run.ts +443 -0
  165. package/src/jev/train.test.ts +40 -0
  166. package/src/jev/train.ts +41 -0
  167. package/src/linux/helper.test.ts +481 -0
  168. package/src/linux/profile.ts +25 -0
  169. package/src/macos/helper.test.ts +988 -0
  170. package/src/macos/profile.ts +19 -0
  171. package/src/preview/controller.test.ts +308 -0
  172. package/src/preview/controller.ts +262 -0
  173. package/src/preview/surface.test.ts +68 -0
  174. package/src/preview/surface.ts +45 -0
  175. package/src/skill.test.ts +34 -0
  176. package/src/windows/maintenance.ts +8 -7
  177. package/src/windows/profile.ts +19 -0
  178. package/dist/shell.d.ts +0 -56
  179. package/dist/shell.d.ts.map +0 -1
  180. package/dist/shell.js +0 -189
  181. package/dist/shell.js.map +0 -1
  182. package/dist/temporary-files.d.ts +0 -2
  183. package/dist/temporary-files.d.ts.map +0 -1
  184. package/dist/temporary-files.js +0 -10
  185. package/dist/temporary-files.js.map +0 -1
  186. package/dist/tools/applescript.d.ts +0 -2
  187. package/dist/tools/applescript.d.ts.map +0 -1
  188. package/dist/tools/applescript.js +0 -50
  189. package/dist/tools/applescript.js.map +0 -1
  190. package/dist/tools/click.d.ts +0 -2
  191. package/dist/tools/click.d.ts.map +0 -1
  192. package/dist/tools/click.js +0 -58
  193. package/dist/tools/click.js.map +0 -1
  194. package/dist/tools/clipboard.d.ts +0 -2
  195. package/dist/tools/clipboard.d.ts.map +0 -1
  196. package/dist/tools/clipboard.js +0 -71
  197. package/dist/tools/clipboard.js.map +0 -1
  198. package/dist/tools/key.d.ts +0 -11
  199. package/dist/tools/key.d.ts.map +0 -1
  200. package/dist/tools/key.js +0 -143
  201. package/dist/tools/key.js.map +0 -1
  202. package/dist/tools/open.d.ts +0 -2
  203. package/dist/tools/open.d.ts.map +0 -1
  204. package/dist/tools/open.js +0 -91
  205. package/dist/tools/open.js.map +0 -1
  206. package/dist/tools/screenshot.d.ts +0 -2
  207. package/dist/tools/screenshot.d.ts.map +0 -1
  208. package/dist/tools/screenshot.js +0 -162
  209. package/dist/tools/screenshot.js.map +0 -1
  210. package/dist/tools/type.d.ts +0 -8
  211. package/dist/tools/type.d.ts.map +0 -1
  212. package/dist/tools/type.js +0 -65
  213. package/dist/tools/type.js.map +0 -1
  214. package/dist/windows/artifact.d.ts +0 -3
  215. package/dist/windows/artifact.d.ts.map +0 -1
  216. package/dist/windows/artifact.js +0 -57
  217. package/dist/windows/artifact.js.map +0 -1
  218. package/dist/windows/backend.d.ts +0 -11
  219. package/dist/windows/backend.d.ts.map +0 -1
  220. package/dist/windows/backend.js +0 -122
  221. package/dist/windows/backend.js.map +0 -1
  222. package/dist/windows/contracts.d.ts +0 -1627
  223. package/dist/windows/contracts.d.ts.map +0 -1
  224. package/dist/windows/contracts.js +0 -137
  225. package/dist/windows/contracts.js.map +0 -1
  226. package/dist/windows/control-service.d.ts +0 -12
  227. package/dist/windows/control-service.d.ts.map +0 -1
  228. package/dist/windows/control-service.js +0 -65
  229. package/dist/windows/control-service.js.map +0 -1
  230. package/dist/windows/guidance.d.ts +0 -3
  231. package/dist/windows/guidance.d.ts.map +0 -1
  232. package/dist/windows/guidance.js +0 -20
  233. package/dist/windows/guidance.js.map +0 -1
  234. package/dist/windows/protocol.d.ts +0 -9
  235. package/dist/windows/protocol.d.ts.map +0 -1
  236. package/dist/windows/protocol.js +0 -32
  237. package/dist/windows/protocol.js.map +0 -1
  238. package/dist/windows/transport.d.ts +0 -23
  239. package/dist/windows/transport.d.ts.map +0 -1
  240. package/dist/windows/transport.js.map +0 -1
  241. package/src/shell.test.ts +0 -186
  242. package/src/shell.ts +0 -213
  243. package/src/temporary-files.test.ts +0 -18
  244. package/src/temporary-files.ts +0 -9
  245. package/src/tools/applescript-serialize.test.ts +0 -74
  246. package/src/tools/applescript.ts +0 -53
  247. package/src/tools/click.ts +0 -60
  248. package/src/tools/clipboard.ts +0 -72
  249. package/src/tools/key.ts +0 -155
  250. package/src/tools/open.ts +0 -96
  251. package/src/tools/screenshot.test.ts +0 -137
  252. package/src/tools/screenshot.ts +0 -180
  253. package/src/tools/type.ts +0 -68
  254. package/src/tools.test.ts +0 -94
  255. package/src/windows/action-contracts.test.ts +0 -13
  256. package/src/windows/artifact.test.ts +0 -16
  257. package/src/windows/artifact.ts +0 -57
  258. package/src/windows/backend.test.ts +0 -41
  259. package/src/windows/backend.ts +0 -122
  260. package/src/windows/contracts.test.ts +0 -81
  261. package/src/windows/contracts.ts +0 -143
  262. package/src/windows/control-service.test.ts +0 -58
  263. package/src/windows/control-service.ts +0 -68
  264. package/src/windows/guidance.test.ts +0 -29
  265. package/src/windows/guidance.ts +0 -21
  266. package/src/windows/model-contract.test.ts +0 -37
  267. package/src/windows/protocol.ts +0 -27
  268. package/src/windows/text-contracts.test.ts +0 -14
  269. package/src/windows/window-typing.test.ts +0 -14
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import type { PlatformProfile } from '../backend/backend.js';
3
+ import { CONTRACT_PROTOCOL_VERSION } from '../backend/rpc.js';
4
+ import { verifyHelperArtifact } from '../helper/artifact.js';
5
+
6
+ /** The universal helper built by `native/macos/build.sh`. */
7
+ export const macosHelperPath = fileURLToPath(new URL('../../bin/darwin-universal/moxxy-computer', import.meta.url));
8
+
9
+ export const macosProfile: PlatformProfile = {
10
+ platform: 'darwin',
11
+ protocolVersion: CONTRACT_PROTOCOL_VERSION,
12
+ helperPath: macosHelperPath,
13
+ helperArgs: [],
14
+ verifyHelper: () => verifyHelperArtifact(macosHelperPath, CONTRACT_PROTOCOL_VERSION),
15
+ unavailableMessage: 'The macOS Computer Use helper is missing or does not match this version. Reinstall Moxxy; chat remains available.',
16
+ // Launching an app and letting it settle can take several seconds on its own.
17
+ timeoutMs: 30_000,
18
+ previewCodecs: ['h264', 'jpeg'],
19
+ };
@@ -0,0 +1,308 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { PreviewController, type PreviewChunk, type PreviewCodec, type PreviewImage, type PreviewMessage, type PreviewSource } from './controller.js';
3
+
4
+ const image = (label: string): PreviewImage => ({ mediaType: 'image/jpeg', base64: label, width: 640, height: 400 });
5
+
6
+ /** A producer that records what it was asked to do; `start` can be made to fail. */
7
+ function source(fail?: string, codecs: readonly PreviewCodec[] = ['jpeg']): PreviewSource & { calls: string[] } {
8
+ const calls: string[] = [];
9
+ return {
10
+ calls,
11
+ codecs,
12
+ start: async (fps, codec) => { calls.push(codec === 'jpeg' ? `start ${fps}` : `start ${fps} ${codec}`); if (fail) throw new Error(fail); },
13
+ stop: async () => { calls.push('stop'); },
14
+ keyframe: async () => { calls.push('keyframe'); },
15
+ };
16
+ }
17
+ const video = () => source(undefined, ['h264', 'jpeg']);
18
+ const chunk = (seq: number, key = false): PreviewChunk => ({ seq, key, codec: 'avc1.4d001f', data: `chunk${seq}`, timestamp: seq * 200_000, width: 640, height: 400 });
19
+ const chunks = (messages: PreviewMessage[]) => messages.flatMap((message) => (message.type === 'chunk' ? [message.data] : []));
20
+
21
+ function viewer(controller: PreviewController): { messages: PreviewMessage[]; leave: () => void } {
22
+ const messages: PreviewMessage[] = [];
23
+ return { messages, leave: controller.subscribe((message) => messages.push(message)) };
24
+ }
25
+ const frames = (messages: PreviewMessage[]) => messages.flatMap((message) => (message.type === 'frame' ? [message.image.base64] : []));
26
+ const states = (messages: PreviewMessage[]) => messages.flatMap((message) => (message.type === 'state' ? [message.state] : []));
27
+
28
+ beforeEach(() => { vi.useFakeTimers(); });
29
+ afterEach(() => { vi.useRealTimers(); });
30
+
31
+ describe('PreviewController', () => {
32
+ it('produces frames only while someone watches a running turn', async () => {
33
+ const controller = new PreviewController();
34
+ const helper = source();
35
+ controller.attach(helper);
36
+ expect(helper.calls).toEqual([]);
37
+ const first = viewer(controller);
38
+ const second = viewer(controller);
39
+ await vi.advanceTimersByTimeAsync(0);
40
+ expect(helper.calls).toEqual(['start 2']);
41
+ first.leave();
42
+ await vi.advanceTimersByTimeAsync(0);
43
+ expect(helper.calls).toEqual(['start 2']);
44
+ second.leave();
45
+ await vi.advanceTimersByTimeAsync(0);
46
+ expect(helper.calls).toEqual(['start 2', 'stop']);
47
+ });
48
+
49
+ it('starts the producer when a turn begins while a viewer is already waiting', async () => {
50
+ const controller = new PreviewController();
51
+ const watching = viewer(controller);
52
+ expect(states(watching.messages)).toEqual(['stopped']);
53
+ const helper = source();
54
+ controller.attach(helper);
55
+ await vi.advanceTimersByTimeAsync(0);
56
+ expect(helper.calls).toEqual(['start 2']);
57
+ controller.frame(helper, image('a'));
58
+ expect(states(watching.messages)).toEqual(['stopped', 'live']);
59
+ expect(frames(watching.messages)).toEqual(['a']);
60
+ });
61
+
62
+ it('keeps only the latest frame when frames arrive faster than the viewer rate', async () => {
63
+ const controller = new PreviewController({ fps: 2 });
64
+ const helper = source();
65
+ controller.attach(helper);
66
+ const watching = viewer(controller);
67
+ for (const label of ['a', 'b', 'c', 'd']) controller.frame(helper, image(label));
68
+ expect(frames(watching.messages)).toEqual(['a']);
69
+ await vi.advanceTimersByTimeAsync(500);
70
+ expect(frames(watching.messages)).toEqual(['a', 'd']);
71
+ await vi.advanceTimersByTimeAsync(2000);
72
+ expect(frames(watching.messages)).toEqual(['a', 'd']);
73
+ });
74
+
75
+ it('turns stale when the producer goes quiet and live again with its next sign of life', async () => {
76
+ const controller = new PreviewController({ staleAfterMs: 3000 });
77
+ const helper = source();
78
+ controller.attach(helper);
79
+ const watching = viewer(controller);
80
+ controller.frame(helper, image('a'));
81
+ await vi.advanceTimersByTimeAsync(2000);
82
+ controller.frame(helper, undefined);
83
+ await vi.advanceTimersByTimeAsync(2000);
84
+ expect(states(watching.messages)).toEqual(['stopped', 'live']);
85
+ await vi.advanceTimersByTimeAsync(1500);
86
+ expect(states(watching.messages)).toEqual(['stopped', 'live', 'stale']);
87
+ controller.frame(helper, undefined);
88
+ expect(states(watching.messages)).toEqual(['stopped', 'live', 'stale', 'live']);
89
+ expect(frames(watching.messages)).toEqual(['a']);
90
+ });
91
+
92
+ it('says why there is no picture when the producer cannot start or fails', async () => {
93
+ const controller = new PreviewController();
94
+ const watching = viewer(controller);
95
+ controller.attach(source('Screen Recording is not allowed'));
96
+ await vi.advanceTimersByTimeAsync(0);
97
+ expect(watching.messages.at(-1)).toEqual({ type: 'state', state: 'unavailable', reason: 'Screen Recording is not allowed' });
98
+ const helper = source();
99
+ controller.attach(helper);
100
+ await vi.advanceTimersByTimeAsync(0);
101
+ controller.failed(helper, 'The window closed');
102
+ expect(watching.messages.at(-1)).toEqual({ type: 'state', state: 'unavailable', reason: 'The window closed' });
103
+ });
104
+
105
+ it('stops and forgets the picture when the turn ends, so nothing of it is shown afterwards', async () => {
106
+ const controller = new PreviewController();
107
+ const helper = source();
108
+ const detach = controller.attach(helper);
109
+ const watching = viewer(controller);
110
+ controller.frame(helper, image('a'));
111
+ detach();
112
+ await vi.advanceTimersByTimeAsync(0);
113
+ expect(watching.messages.at(-1)).toEqual({ type: 'state', state: 'stopped' });
114
+ expect(controller.snapshot()).toEqual({ state: 'stopped' });
115
+ // The helper is gone with its turn; it is not asked to stop, and its late frames are dropped.
116
+ expect(helper.calls).toEqual(['start 2']);
117
+ controller.frame(helper, image('late'));
118
+ await vi.advanceTimersByTimeAsync(5000);
119
+ expect(frames(watching.messages)).toEqual(['a']);
120
+ expect(states(watching.messages).at(-1)).toBe('stopped');
121
+ });
122
+
123
+ it('gives a late viewer the current state and the latest frame at once', async () => {
124
+ const controller = new PreviewController();
125
+ const helper = source();
126
+ controller.attach(helper);
127
+ viewer(controller);
128
+ controller.frame(helper, image('a'));
129
+ const late = viewer(controller);
130
+ expect(late.messages).toEqual([{ type: 'state', state: 'live' }, { type: 'frame', seq: 1, image: image('a') }]);
131
+ expect(controller.snapshot()).toEqual({ state: 'live', frame: { seq: 1, image: image('a') } });
132
+ });
133
+
134
+ it('follows the newest turn and ignores frames of an older one', async () => {
135
+ const controller = new PreviewController();
136
+ const older = source();
137
+ const newer = source();
138
+ controller.attach(older);
139
+ const watching = viewer(controller);
140
+ await vi.advanceTimersByTimeAsync(0);
141
+ controller.attach(newer);
142
+ await vi.advanceTimersByTimeAsync(0);
143
+ expect(older.calls).toEqual(['start 2', 'stop']);
144
+ expect(newer.calls).toEqual(['start 2']);
145
+ controller.frame(older, image('old'));
146
+ controller.frame(newer, image('new'));
147
+ expect(frames(watching.messages)).toEqual(['new']);
148
+ });
149
+
150
+ it('runs video at 30 pictures a second, up to 30 when the viewer chooses', async () => {
151
+ const controller = new PreviewController();
152
+ const helper = video();
153
+ controller.attach(helper);
154
+ controller.subscribe(() => undefined, ['h264', 'jpeg']);
155
+ await vi.advanceTimersByTimeAsync(0);
156
+ controller.setFps(10);
157
+ await vi.advanceTimersByTimeAsync(0);
158
+ controller.setFps(120);
159
+ await vi.advanceTimersByTimeAsync(0);
160
+ expect(helper.calls).toEqual(['start 30 h264', 'start 10 h264', 'start 30 h264']);
161
+ });
162
+
163
+ it('keeps the rate of single pictures between 1 and 5 and restarts a running producer at the new rate', async () => {
164
+ const controller = new PreviewController();
165
+ const helper = source();
166
+ controller.attach(helper);
167
+ viewer(controller);
168
+ await vi.advanceTimersByTimeAsync(0);
169
+ controller.setFps(30);
170
+ await vi.advanceTimersByTimeAsync(0);
171
+ controller.setFps(0);
172
+ await vi.advanceTimersByTimeAsync(0);
173
+ controller.setFps(1);
174
+ await vi.advanceTimersByTimeAsync(0);
175
+ expect(helper.calls).toEqual(['start 2', 'start 5', 'start 1']);
176
+ });
177
+ });
178
+
179
+ describe('PreviewController video', () => {
180
+ const watch = (controller: PreviewController, codecs?: readonly PreviewCodec[]) => {
181
+ const messages: PreviewMessage[] = [];
182
+ const listener = (message: PreviewMessage) => { messages.push(message); };
183
+ return { messages, listener, leave: controller.subscribe(listener, codecs) };
184
+ };
185
+
186
+ it('uses video only when the helper makes it and every viewer can show it', async () => {
187
+ const controller = new PreviewController();
188
+ const helper = video();
189
+ controller.attach(helper);
190
+ const modern = watch(controller, ['h264', 'jpeg']);
191
+ await vi.advanceTimersByTimeAsync(0);
192
+ // A new encoder starts with a key frame by itself.
193
+ expect(helper.calls).toEqual(['start 30 h264']);
194
+ // A viewer that only shows pictures joins: everyone gets pictures.
195
+ const plain = watch(controller);
196
+ await vi.advanceTimersByTimeAsync(0);
197
+ expect(helper.calls.at(-1)).toBe('start 2');
198
+ plain.leave();
199
+ await vi.advanceTimersByTimeAsync(0);
200
+ expect(helper.calls).toEqual(['start 30 h264', 'start 2', 'start 30 h264']);
201
+ modern.leave();
202
+ });
203
+
204
+ it('keeps pictures when the helper has no video', async () => {
205
+ const controller = new PreviewController();
206
+ const helper = source();
207
+ controller.attach(helper);
208
+ watch(controller, ['h264', 'jpeg']);
209
+ await vi.advanceTimersByTimeAsync(0);
210
+ expect(helper.calls).toEqual(['start 2']);
211
+ });
212
+
213
+ it('lets a viewer say later what it can show', async () => {
214
+ const controller = new PreviewController();
215
+ const helper = video();
216
+ controller.attach(helper);
217
+ const late = watch(controller);
218
+ await vi.advanceTimersByTimeAsync(0);
219
+ controller.accept(late.listener, ['h264', 'jpeg']);
220
+ await vi.advanceTimersByTimeAsync(0);
221
+ expect(helper.calls).toEqual(['start 2', 'start 30 h264']);
222
+ });
223
+
224
+ it('gives a viewer nothing until a key frame, then every chunk in order', async () => {
225
+ const controller = new PreviewController();
226
+ const helper = video();
227
+ controller.attach(helper);
228
+ const first = watch(controller, ['h264']);
229
+ await vi.advanceTimersByTimeAsync(0);
230
+ controller.chunk(helper, chunk(1));
231
+ expect(chunks(first.messages)).toEqual([]);
232
+ controller.chunk(helper, chunk(2, true));
233
+ controller.chunk(helper, chunk(3));
234
+ expect(chunks(first.messages)).toEqual(['chunk2', 'chunk3']);
235
+ expect(states(first.messages).at(-1)).toBe('live');
236
+ // A second viewer joins in the middle of the stream: it waits for its own key frame.
237
+ const second = watch(controller, ['h264']);
238
+ await vi.advanceTimersByTimeAsync(0);
239
+ expect(helper.calls.at(-1)).toBe('keyframe');
240
+ controller.chunk(helper, chunk(4));
241
+ expect(chunks(first.messages)).toEqual(['chunk2', 'chunk3', 'chunk4']);
242
+ expect(chunks(second.messages)).toEqual([]);
243
+ controller.chunk(helper, chunk(5, true));
244
+ expect(chunks(second.messages)).toEqual(['chunk5']);
245
+ expect(controller.snapshot()).toEqual({ state: 'live' });
246
+ });
247
+
248
+ it('shows the pictures of a producer that was asked for video and cannot make it', async () => {
249
+ const controller = new PreviewController();
250
+ const helper = video();
251
+ controller.attach(helper);
252
+ const only = watch(controller, ['h264', 'jpeg']);
253
+ await vi.advanceTimersByTimeAsync(0);
254
+ expect(helper.calls).toEqual(['start 30 h264']);
255
+
256
+ controller.frame(helper, image('still'));
257
+
258
+ expect(frames(only.messages)).toEqual(['still']);
259
+ expect(states(only.messages).at(-1)).toBe('live');
260
+ });
261
+
262
+ it('drops deltas after a lost chunk until the next key frame and asks for one', async () => {
263
+ const controller = new PreviewController();
264
+ const helper = video();
265
+ controller.attach(helper);
266
+ const only = watch(controller, ['h264']);
267
+ await vi.advanceTimersByTimeAsync(0);
268
+ controller.chunk(helper, chunk(1, true));
269
+ controller.chunk(helper, chunk(2));
270
+ helper.calls.length = 0;
271
+ controller.chunk(helper, chunk(4));
272
+ controller.chunk(helper, chunk(5));
273
+ expect(chunks(only.messages)).toEqual(['chunk1', 'chunk2']);
274
+ expect(helper.calls).toEqual(['keyframe']);
275
+ controller.chunk(helper, chunk(6, true));
276
+ controller.chunk(helper, chunk(7));
277
+ expect(chunks(only.messages)).toEqual(['chunk1', 'chunk2', 'chunk6', 'chunk7']);
278
+ });
279
+
280
+ it('starts a viewer that fell behind again from a key frame', async () => {
281
+ const controller = new PreviewController();
282
+ const helper = video();
283
+ controller.attach(helper);
284
+ const slow = watch(controller, ['h264']);
285
+ const other = watch(controller, ['h264']);
286
+ await vi.advanceTimersByTimeAsync(0);
287
+ controller.chunk(helper, chunk(1, true));
288
+ helper.calls.length = 0;
289
+ controller.keyframe(slow.listener);
290
+ expect(helper.calls).toEqual(['keyframe']);
291
+ controller.chunk(helper, chunk(2));
292
+ expect(chunks(slow.messages)).toEqual(['chunk1']);
293
+ expect(chunks(other.messages)).toEqual(['chunk1', 'chunk2']);
294
+ controller.chunk(helper, chunk(3, true));
295
+ expect(chunks(slow.messages)).toEqual(['chunk1', 'chunk3']);
296
+ });
297
+
298
+ it('goes stale when chunks stop, like pictures do', async () => {
299
+ const controller = new PreviewController({ staleAfterMs: 500 });
300
+ const helper = video();
301
+ controller.attach(helper);
302
+ const only = watch(controller, ['h264']);
303
+ await vi.advanceTimersByTimeAsync(0);
304
+ controller.chunk(helper, chunk(1, true));
305
+ await vi.advanceTimersByTimeAsync(600);
306
+ expect(states(only.messages).at(-1)).toBe('stale');
307
+ });
308
+ });
@@ -0,0 +1,262 @@
1
+ /** What the human sees of the app the agent works in. Never shown to the model or written to the session log. */
2
+ export interface PreviewImage {
3
+ readonly mediaType: 'image/jpeg';
4
+ readonly base64: string;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ }
8
+
9
+ /**
10
+ * `live`: frames are current. `stale`: the producer went quiet. `unavailable`:
11
+ * it cannot produce (with the reason). `stopped`: no turn is using the computer.
12
+ */
13
+ export type PreviewState = 'live' | 'stale' | 'unavailable' | 'stopped';
14
+
15
+ /** How the picture travels: single JPEG frames, or an H.264 stream for viewers that can decode one. */
16
+ export type PreviewCodec = 'jpeg' | 'h264';
17
+ const isCodec = (value: unknown): value is PreviewCodec => value === 'jpeg' || value === 'h264';
18
+ /** The codecs in `value` this controller knows; anything else in the list is ignored. */
19
+ export const previewCodecs = (value: unknown): PreviewCodec[] => (Array.isArray(value) ? value.filter(isCodec) : []);
20
+
21
+ /** One access unit of the video, Annex B, base64. A key chunk decodes without anything before it. */
22
+ export interface PreviewChunk {
23
+ readonly seq: number;
24
+ readonly key: boolean;
25
+ /** RFC 6381 codec string, e.g. `avc1.4d001f`. */
26
+ readonly codec: string;
27
+ readonly data: string;
28
+ /** Microseconds. */
29
+ readonly timestamp: number;
30
+ readonly width: number;
31
+ readonly height: number;
32
+ }
33
+
34
+ export type PreviewMessage =
35
+ | { readonly type: 'frame'; readonly seq: number; readonly image: PreviewImage }
36
+ | ({ readonly type: 'chunk' } & PreviewChunk)
37
+ | { readonly type: 'state'; readonly state: PreviewState; readonly reason?: string };
38
+
39
+ export interface PreviewSnapshot {
40
+ readonly state: PreviewState;
41
+ readonly reason?: string;
42
+ readonly frame?: { readonly seq: number; readonly image: PreviewImage };
43
+ }
44
+
45
+ /** One turn's helper, as far as the preview is concerned. `start` on a running producer changes its rate. */
46
+ export interface PreviewSource {
47
+ /** What this helper can produce. */
48
+ readonly codecs: readonly PreviewCodec[];
49
+ start(fps: number, codec: PreviewCodec): Promise<void>;
50
+ stop(): Promise<void>;
51
+ /** Make the next chunk a key frame. */
52
+ keyframe(): Promise<void>;
53
+ }
54
+
55
+ /** Pictures a second. Video is smooth; single pictures are each a whole JPEG, so they stay slow. */
56
+ export const PREVIEW_FPS = { h264: { default: 30, max: 30 }, jpeg: { default: 2, max: 5 }, min: 1 } as const;
57
+
58
+ type Listener = (message: PreviewMessage) => void;
59
+ interface Viewer {
60
+ /** What this viewer can show. */
61
+ codecs: readonly PreviewCodec[];
62
+ /** In a video stream: nothing is sent to it until the next key frame. */
63
+ waiting: boolean;
64
+ }
65
+
66
+ /**
67
+ * Connects the viewers of the preview to the helper of the turn that is using
68
+ * the computer. The helper captures only while someone watches; viewers get the
69
+ * latest frame at the chosen rate, never a backlog. Video is used when the
70
+ * helper makes it and every viewer can decode it; a viewer that cannot follow
71
+ * the stream gets nothing until the next key frame.
72
+ */
73
+ export class PreviewController {
74
+ private readonly listeners = new Map<Listener, Viewer>();
75
+ private readonly staleAfterMs: number;
76
+ /** What the viewer asked for; without a wish each kind of stream runs at its own default. */
77
+ private wish?: number;
78
+ /** The newest attached turn; older ones are ignored. */
79
+ private active?: PreviewSource;
80
+ /** What the active producer was last asked to do. */
81
+ private running?: { readonly source: PreviewSource; readonly fps: number; readonly codec: PreviewCodec };
82
+ /** Sequence number of the last chunk the producer sent. */
83
+ private lastChunk?: number;
84
+ private state: PreviewState = 'stopped';
85
+ private reason?: string;
86
+ private seq = 0;
87
+ private latest?: { seq: number; image: PreviewImage };
88
+ private deliveredAt = Number.NEGATIVE_INFINITY;
89
+ private flush?: ReturnType<typeof setTimeout>;
90
+ private quiet?: ReturnType<typeof setTimeout>;
91
+
92
+ constructor(options: { fps?: number; staleAfterMs?: number } = {}) {
93
+ this.wish = options.fps;
94
+ this.staleAfterMs = options.staleAfterMs ?? 3000;
95
+ }
96
+
97
+ /** A viewer joins: it gets the current state and latest frame at once, then every change. */
98
+ subscribe(listener: Listener, codecs: readonly PreviewCodec[] = ['jpeg']): () => void {
99
+ this.listeners.set(listener, { codecs, waiting: true });
100
+ listener(this.stateMessage());
101
+ if (this.latest) listener({ type: 'frame', ...this.latest });
102
+ this.sync();
103
+ return () => {
104
+ if (!this.listeners.delete(listener)) return;
105
+ this.sync();
106
+ };
107
+ }
108
+
109
+ /** A viewer says what it can show, after it joined. */
110
+ accept(listener: Listener, codecs: readonly PreviewCodec[]): void {
111
+ const viewer = this.listeners.get(listener);
112
+ if (!viewer) return;
113
+ viewer.codecs = codecs;
114
+ this.sync();
115
+ }
116
+
117
+ /** A viewer lost its place in the video (it dropped chunks or its decoder failed). */
118
+ keyframe(listener: Listener): void {
119
+ const viewer = this.listeners.get(listener);
120
+ if (!viewer || this.running?.codec !== 'h264') return;
121
+ viewer.waiting = true;
122
+ this.askForKey(this.running.source);
123
+ }
124
+
125
+ snapshot(): PreviewSnapshot {
126
+ return { state: this.state, ...(this.reason ? { reason: this.reason } : {}), ...(this.latest ? { frame: this.latest } : {}) };
127
+ }
128
+
129
+ setFps(fps: number): void {
130
+ this.wish = fps;
131
+ this.sync();
132
+ }
133
+
134
+ /** A turn started using the computer. Returns the detach to call when its helper is gone. */
135
+ attach(source: PreviewSource): () => void {
136
+ this.active = source;
137
+ this.forget();
138
+ this.sync();
139
+ return () => {
140
+ if (this.active !== source) return;
141
+ this.active = undefined;
142
+ // The helper leaves with its turn, so there is nothing left to stop.
143
+ this.running = undefined;
144
+ this.forget();
145
+ this.setState('stopped');
146
+ };
147
+ }
148
+
149
+ /** A frame from a producer, or `undefined` for "still running, nothing changed". */
150
+ frame(source: PreviewSource, image: PreviewImage | undefined): void {
151
+ if (source !== this.active || this.listeners.size === 0) return;
152
+ this.alive();
153
+ // A producer asked for video that sends a picture cannot make video: the picture is shown.
154
+ if (!image) return;
155
+ this.latest = { seq: ++this.seq, image };
156
+ const wait = this.deliveredAt + 1000 / this.rate('jpeg') - Date.now();
157
+ if (wait <= 0) this.deliver();
158
+ else this.flush ??= setTimeout(() => this.deliver(), wait);
159
+ }
160
+
161
+ /** A piece of the video from a producer. */
162
+ chunk(source: PreviewSource, chunk: PreviewChunk): void {
163
+ if (source !== this.active || this.running?.codec !== 'h264' || this.listeners.size === 0) return;
164
+ this.alive();
165
+ // A chunk went missing: what follows cannot be decoded until the stream starts again.
166
+ const lost = !chunk.key && this.lastChunk !== undefined && chunk.seq !== this.lastChunk + 1;
167
+ this.lastChunk = chunk.seq;
168
+ if (lost) {
169
+ for (const viewer of this.listeners.values()) viewer.waiting = true;
170
+ this.askForKey(source);
171
+ }
172
+ for (const [listener, viewer] of this.listeners) {
173
+ if (chunk.key) viewer.waiting = false;
174
+ if (!viewer.waiting) listener({ type: 'chunk', ...chunk });
175
+ }
176
+ }
177
+
178
+ failed(source: PreviewSource, reason: string): void {
179
+ if (source !== this.active) return;
180
+ clearTimeout(this.quiet);
181
+ this.setState('unavailable', reason);
182
+ }
183
+
184
+ private alive(): void {
185
+ this.setState('live');
186
+ clearTimeout(this.quiet);
187
+ this.quiet = setTimeout(() => this.setState('stale'), this.staleAfterMs);
188
+ }
189
+
190
+ private askForKey(source: PreviewSource): void {
191
+ void source.keyframe().catch(() => undefined);
192
+ }
193
+
194
+ private codecFor(source: PreviewSource): PreviewCodec {
195
+ const everyone = [...this.listeners.values()].every((viewer) => viewer.codecs.includes('h264'));
196
+ return everyone && source.codecs.includes('h264') ? 'h264' : 'jpeg';
197
+ }
198
+
199
+ private rate(codec: PreviewCodec): number {
200
+ const { default: usual, max } = PREVIEW_FPS[codec];
201
+ return Math.min(max, Math.max(PREVIEW_FPS.min, Math.round(this.wish ?? usual)));
202
+ }
203
+
204
+ private deliver(): void {
205
+ clearTimeout(this.flush);
206
+ this.flush = undefined;
207
+ if (!this.latest) return;
208
+ this.deliveredAt = Date.now();
209
+ this.emit({ type: 'frame', ...this.latest });
210
+ }
211
+
212
+ /** Makes the producer match what is wanted: running at the current rate and codec only while watched. */
213
+ private sync(): void {
214
+ const codec = this.active && this.codecFor(this.active);
215
+ const wanted = this.active && codec && this.listeners.size > 0 ? { source: this.active, fps: this.rate(codec), codec } : undefined;
216
+ const running = this.running;
217
+ if (running && running.source !== wanted?.source) {
218
+ this.running = undefined;
219
+ clearTimeout(this.quiet);
220
+ clearTimeout(this.flush);
221
+ this.flush = undefined;
222
+ void running.source.stop().catch(() => undefined);
223
+ }
224
+ if (!wanted) return;
225
+ const same = this.running?.source === wanted.source && this.running.fps === wanted.fps && this.running.codec === wanted.codec;
226
+ if (!same) {
227
+ if (this.running?.codec !== wanted.codec) {
228
+ // The other kind of stream starts from nothing: no old picture, no place in the old video.
229
+ this.forget();
230
+ for (const viewer of this.listeners.values()) viewer.waiting = true;
231
+ }
232
+ this.running = wanted;
233
+ wanted.source.start(wanted.fps, wanted.codec).catch((error: unknown) => this.failed(wanted.source, error instanceof Error ? error.message : String(error)));
234
+ }
235
+ // Whoever is waiting in a video stream (a new viewer, or all of them after a switch) needs a key frame.
236
+ if (wanted.codec === 'h264' && [...this.listeners.values()].some((viewer) => viewer.waiting) && this.lastChunk !== undefined) this.askForKey(wanted.source);
237
+ }
238
+
239
+ private forget(): void {
240
+ clearTimeout(this.quiet);
241
+ clearTimeout(this.flush);
242
+ this.flush = undefined;
243
+ this.latest = undefined;
244
+ this.lastChunk = undefined;
245
+ this.deliveredAt = Number.NEGATIVE_INFINITY;
246
+ }
247
+
248
+ private setState(state: PreviewState, reason?: string): void {
249
+ if (this.state === state && this.reason === reason) return;
250
+ this.state = state;
251
+ this.reason = reason;
252
+ this.emit(this.stateMessage());
253
+ }
254
+
255
+ private stateMessage(): PreviewMessage {
256
+ return { type: 'state', state: this.state, ...(this.reason ? { reason: this.reason } : {}) };
257
+ }
258
+
259
+ private emit(message: PreviewMessage): void {
260
+ for (const listener of this.listeners.keys()) listener(message);
261
+ }
262
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { PreviewController, type PreviewSource } from './controller.js';
3
+ import { COMPUTER_PREVIEW_SURFACE, buildComputerPreviewSurface } from './surface.js';
4
+
5
+ const helper = (calls: string[], codecs: PreviewSource['codecs'] = ['jpeg']): PreviewSource => ({
6
+ codecs,
7
+ start: async (fps, codec) => { calls.push(codec === 'jpeg' ? `start ${fps}` : `start ${fps} ${codec}`); },
8
+ stop: async () => { calls.push('stop'); },
9
+ keyframe: async () => { calls.push('keyframe'); },
10
+ });
11
+ const settle = () => new Promise((resolve) => setImmediate(resolve));
12
+
13
+ describe('computer-preview surface', () => {
14
+ it('is the viewer of the preview: opening it starts frames, closing it stops them', async () => {
15
+ const controller = new PreviewController();
16
+ const calls: string[] = [];
17
+ const source = helper(calls);
18
+ controller.attach(source);
19
+ const surface = buildComputerPreviewSurface(controller);
20
+ expect(surface.kind).toBe(COMPUTER_PREVIEW_SURFACE);
21
+ const instance = await surface.open({ cwd: process.cwd() });
22
+ const payloads: unknown[] = [];
23
+ const off = instance.onData((payload) => payloads.push(payload));
24
+ await settle();
25
+ expect(calls).toEqual(['start 2']);
26
+ controller.frame(source, { mediaType: 'image/jpeg', base64: 'a', width: 2, height: 1 });
27
+ expect(payloads.at(-1)).toEqual({ type: 'frame', seq: 1, image: { mediaType: 'image/jpeg', base64: 'a', width: 2, height: 1 } });
28
+ expect(instance.snapshot?.()).toEqual({ state: 'live', frame: { seq: 1, image: { mediaType: 'image/jpeg', base64: 'a', width: 2, height: 1 } } });
29
+ off();
30
+ await instance.close();
31
+ await settle();
32
+ expect(calls).toEqual(['start 2', 'stop']);
33
+ });
34
+
35
+ it('lets the viewer choose the frame rate and ignores anything else it sends', async () => {
36
+ const controller = new PreviewController();
37
+ const calls: string[] = [];
38
+ controller.attach(helper(calls));
39
+ const instance = await buildComputerPreviewSurface(controller).open({ cwd: process.cwd() });
40
+ instance.onData(() => undefined);
41
+ await settle();
42
+ await instance.input({ type: 'configure', fps: 4 });
43
+ await instance.input({ type: 'configure', fps: 'fast' });
44
+ await instance.input({ type: 'click', x: 1, y: 1 });
45
+ await settle();
46
+ expect(calls).toEqual(['start 2', 'start 4']);
47
+ });
48
+
49
+ it('switches to video for a viewer that can decode it and passes on its request for a key frame', async () => {
50
+ const controller = new PreviewController();
51
+ const calls: string[] = [];
52
+ const source = helper(calls, ['h264', 'jpeg']);
53
+ controller.attach(source);
54
+ const instance = await buildComputerPreviewSurface(controller).open({ cwd: process.cwd() });
55
+ const payloads: unknown[] = [];
56
+ instance.onData((payload) => payloads.push(payload));
57
+ await settle();
58
+ await instance.input({ type: 'configure', codecs: ['h264', 'jpeg'] });
59
+ await settle();
60
+ expect(calls).toEqual(['start 2', 'start 30 h264']);
61
+ controller.chunk(source, { seq: 1, key: true, codec: 'avc1.4d001f', data: 'AAAA', timestamp: 0, width: 640, height: 400 });
62
+ expect(payloads.at(-1)).toEqual({ type: 'chunk', seq: 1, key: true, codec: 'avc1.4d001f', data: 'AAAA', timestamp: 0, width: 640, height: 400 });
63
+ await instance.input({ type: 'keyframe' });
64
+ await instance.input({ type: 'configure', codecs: ['vp9', 7] });
65
+ await settle();
66
+ expect(calls).toEqual(['start 2', 'start 30 h264', 'keyframe', 'start 2']);
67
+ });
68
+ });