crewly 1.20.35 → 1.20.47

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 (139) hide show
  1. package/config/skills/_common/desktop-guards.sh +485 -0
  2. package/config/skills/_common/desktop-guards.test.sh +242 -0
  3. package/config/skills/_common/desktop-perceive.swift +530 -0
  4. package/config/skills/_common/desktop-presence.swift +343 -0
  5. package/config/skills/_common/lib.sh +6 -0
  6. package/config/skills/agent/_common/desktop-guards.sh +4 -0
  7. package/config/skills/agent/computer-use/SKILL.md +88 -0
  8. package/config/skills/agent/computer-use/execute.sh +249 -3
  9. package/config/skills/agent/core/calendar-create/SKILL.md +10 -0
  10. package/config/skills/agent/core/calendar-create/execute.sh +6 -0
  11. package/config/skills/agent/core/calendar-list/SKILL.md +10 -0
  12. package/config/skills/agent/core/calendar-list/execute.sh +6 -0
  13. package/config/skills/agent/core/docs-read/SKILL.md +10 -0
  14. package/config/skills/agent/core/docs-read/execute.sh +6 -1
  15. package/config/skills/agent/core/docs-write/SKILL.md +10 -0
  16. package/config/skills/agent/core/docs-write/execute.sh +6 -1
  17. package/config/skills/agent/core/drive-read/SKILL.md +10 -0
  18. package/config/skills/agent/core/drive-read/execute.sh +6 -1
  19. package/config/skills/agent/core/drive-search/SKILL.md +10 -0
  20. package/config/skills/agent/core/drive-search/execute.sh +6 -1
  21. package/config/skills/agent/core/drive-upload/SKILL.md +10 -0
  22. package/config/skills/agent/core/drive-upload/execute.sh +6 -1
  23. package/config/skills/agent/core/gmail-read/SKILL.md +10 -0
  24. package/config/skills/agent/core/gmail-read/execute.sh +6 -0
  25. package/config/skills/agent/core/gmail-search/SKILL.md +10 -0
  26. package/config/skills/agent/core/gmail-search/execute.sh +6 -0
  27. package/config/skills/agent/core/gmail-send/SKILL.md +10 -0
  28. package/config/skills/agent/core/gmail-send/execute.sh +6 -0
  29. package/config/skills/agent/core/sheets-read/SKILL.md +10 -0
  30. package/config/skills/agent/core/sheets-read/execute.sh +6 -1
  31. package/config/skills/agent/core/sheets-write/SKILL.md +10 -0
  32. package/config/skills/agent/core/sheets-write/execute.sh +6 -1
  33. package/config/skills/agent/core/slides-create/SKILL.md +10 -0
  34. package/config/skills/agent/core/slides-create/execute.sh +6 -1
  35. package/config/skills/agent/core/slides-read/SKILL.md +10 -0
  36. package/config/skills/agent/core/slides-read/execute.sh +6 -1
  37. package/config/skills/agent/desktop-app-control/SKILL.md +19 -0
  38. package/config/skills/agent/remote-browser/SKILL.md +19 -0
  39. package/config/slack-app-manifest.json +16 -9
  40. package/dist/backend/backend/src/constants.d.ts +18 -4
  41. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  42. package/dist/backend/backend/src/constants.js +16 -4
  43. package/dist/backend/backend/src/constants.js.map +1 -1
  44. package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts +105 -0
  45. package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts.map +1 -0
  46. package/dist/backend/backend/src/controllers/desktop/desktop.controller.js +278 -0
  47. package/dist/backend/backend/src/controllers/desktop/desktop.controller.js.map +1 -0
  48. package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts +21 -0
  49. package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts.map +1 -0
  50. package/dist/backend/backend/src/controllers/desktop/desktop.routes.js +31 -0
  51. package/dist/backend/backend/src/controllers/desktop/desktop.routes.js.map +1 -0
  52. package/dist/backend/backend/src/controllers/google/google.controller.d.ts +8 -0
  53. package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
  54. package/dist/backend/backend/src/controllers/google/google.controller.js +137 -37
  55. package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
  56. package/dist/backend/backend/src/controllers/google/google.routes.d.ts +2 -1
  57. package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
  58. package/dist/backend/backend/src/controllers/google/google.routes.js +4 -2
  59. package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
  60. package/dist/backend/backend/src/controllers/slack/slack-error.utils.d.ts +46 -0
  61. package/dist/backend/backend/src/controllers/slack/slack-error.utils.d.ts.map +1 -0
  62. package/dist/backend/backend/src/controllers/slack/slack-error.utils.js +54 -0
  63. package/dist/backend/backend/src/controllers/slack/slack-error.utils.js.map +1 -0
  64. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  65. package/dist/backend/backend/src/controllers/slack/slack.controller.js +5 -12
  66. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  67. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  68. package/dist/backend/backend/src/routes/api.routes.js +3 -0
  69. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  70. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +9 -0
  72. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  73. package/dist/backend/backend/src/services/google/google-api.client.d.ts +23 -2
  74. package/dist/backend/backend/src/services/google/google-api.client.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/google/google-api.client.js +5 -2
  76. package/dist/backend/backend/src/services/google/google-api.client.js.map +1 -1
  77. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +61 -11
  78. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/google/google-workspace-token.service.js +108 -31
  80. package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
  81. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
  82. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  83. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +87 -5
  84. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  85. package/dist/backend/backend/src/services/slack/slack.service.d.ts +17 -0
  86. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/slack/slack.service.js +32 -0
  88. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  89. package/dist/backend/backend/src/types/slack.types.d.ts +10 -0
  90. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  91. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  92. package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
  93. package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
  94. package/dist/backend/backend/src/utils/incomplete-turn.utils.js +4 -0
  95. package/dist/backend/backend/src/utils/incomplete-turn.utils.js.map +1 -1
  96. package/dist/backend/build-info.json +2 -2
  97. package/dist/cli/backend/src/constants.d.ts +18 -4
  98. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  99. package/dist/cli/backend/src/constants.js +16 -4
  100. package/dist/cli/backend/src/constants.js.map +1 -1
  101. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
  102. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  103. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +87 -5
  104. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  105. package/dist/cli/backend/src/services/slack/slack.service.d.ts +17 -0
  106. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  107. package/dist/cli/backend/src/services/slack/slack.service.js +32 -0
  108. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  109. package/dist/cli/backend/src/types/slack.types.d.ts +10 -0
  110. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  111. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  112. package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
  113. package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
  114. package/dist/cli/backend/src/utils/incomplete-turn.utils.js +4 -0
  115. package/dist/cli/backend/src/utils/incomplete-turn.utils.js.map +1 -1
  116. package/frontend/dist/assets/{index-e079a375.js → index-e7785269.js} +267 -267
  117. package/frontend/dist/index.html +1 -1
  118. package/package.json +1 -1
  119. package/packages/crewly-agent/src/eval/desktop/desktop-tasks.test.ts +96 -0
  120. package/packages/crewly-agent/src/eval/desktop/desktop-tasks.ts +226 -0
  121. package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +10 -1
  122. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +199 -4
  123. package/packages/crewly-agent/src/runtime/computer.tool.test.ts +219 -0
  124. package/packages/crewly-agent/src/runtime/computer.tool.ts +405 -0
  125. package/packages/crewly-agent/src/runtime/desktop-checkpoint.test.ts +136 -0
  126. package/packages/crewly-agent/src/runtime/desktop-checkpoint.ts +231 -0
  127. package/packages/crewly-agent/src/runtime/desktop-recovery.test.ts +100 -0
  128. package/packages/crewly-agent/src/runtime/desktop-recovery.ts +195 -0
  129. package/packages/crewly-agent/src/runtime/desktop-task-runtime.test.ts +251 -0
  130. package/packages/crewly-agent/src/runtime/desktop-task-runtime.ts +423 -0
  131. package/packages/crewly-agent/src/runtime/desktop-task.tool.test.ts +218 -0
  132. package/packages/crewly-agent/src/runtime/desktop-task.tool.ts +343 -0
  133. package/packages/crewly-agent/src/runtime/text-tool-calls.test.ts +144 -0
  134. package/packages/crewly-agent/src/runtime/text-tool-calls.ts +316 -0
  135. package/packages/crewly-agent/src/runtime/text-tool-salvage.test.ts +190 -0
  136. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +17 -0
  137. package/packages/crewly-agent/src/runtime/tool-registry.ts +54 -0
  138. package/packages/crewly-agent/src/runtime/types.ts +16 -1
  139. package/config/skills/agent/vnc-browser/SKILL.md +0 -140
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Tests for the `computer` tool.
3
+ *
4
+ * Two things matter most here. The coordinate conversion: the model points at
5
+ * a 1280-wide screenshot and the click has to land on the real screen, so an
6
+ * off-by-a-factor here misses every button. And the routing: every action
7
+ * must go through the computer-use skill, because that is where the safety
8
+ * rails live — a shortcut straight to the mouse would be a second
9
+ * implementation with no rails on it.
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest';
13
+ import {
14
+ createComputerTool,
15
+ parseSkillOutput,
16
+ scaleFor,
17
+ toScreen,
18
+ toSkillInput,
19
+ } from './computer.tool.js';
20
+
21
+ /** A 1728×1117 Retina Mac — the machine this was written on. */
22
+ const MACBOOK = [{ frame: [0, 0, 1728, 1117] as [number, number, number, number], scale: 2, main: true }];
23
+
24
+ /** Capture what the tool asks the skill to do. */
25
+ function recorder(responses: Record<string, unknown> = {}) {
26
+ const calls: Array<Record<string, unknown>> = [];
27
+ const runSkill = vi.fn(async (input: Record<string, unknown>) => {
28
+ calls.push(input);
29
+ const action = String(input['action']);
30
+ if (action === 'displays') return JSON.stringify({ success: true, displays: MACBOOK });
31
+ if (action === 'screenshot') return JSON.stringify({ action: 'screenshot', path: '/tmp/shot.png', width: 1280, height: 827 });
32
+ return JSON.stringify(responses[action] ?? { success: true, action });
33
+ });
34
+ const readImage = vi.fn(async () => ({ data: 'aGVsbG8=', bytes: 5 }));
35
+ return { calls, runSkill, readImage };
36
+ }
37
+
38
+ describe('scaleFor', () => {
39
+ it('scales a wide screen down to the width the model reasons in', () => {
40
+ const out = scaleFor(MACBOOK);
41
+ expect(out.width).toBe(1280);
42
+ expect(out.factor).toBeCloseTo(1728 / 1280, 5);
43
+ expect(out.screen).toEqual([1728, 1117]);
44
+ // Aspect ratio is kept, or the model's vertical aim would be off.
45
+ expect(out.height).toBe(Math.round(1117 / out.factor));
46
+ });
47
+
48
+ it('never enlarges a screen that is already narrow', () => {
49
+ const out = scaleFor([{ frame: [0, 0, 1024, 768], scale: 1, main: true }]);
50
+ expect(out.factor).toBe(1);
51
+ expect(out.width).toBe(1024);
52
+ });
53
+
54
+ it('falls back to something usable when no display is reported', () => {
55
+ expect(scaleFor([]).factor).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('prefers the main display when several are attached', () => {
59
+ const out = scaleFor([
60
+ { frame: [0, 0, 3840, 2160], scale: 2, main: false },
61
+ { frame: [0, 0, 1440, 900], scale: 2, main: true },
62
+ ]);
63
+ expect(out.screen).toEqual([1440, 900]);
64
+ });
65
+ });
66
+
67
+ describe('toScreen', () => {
68
+ it('maps a model coordinate onto the real screen', () => {
69
+ const { factor } = scaleFor(MACBOOK);
70
+ // Middle of the model's view is the middle of the screen.
71
+ expect(toScreen(640, factor)).toBe(864);
72
+ expect(toScreen(0, factor)).toBe(0);
73
+ expect(toScreen(1280, factor)).toBe(1728);
74
+ });
75
+ });
76
+
77
+ describe('toSkillInput', () => {
78
+ const factor = 1728 / 1280;
79
+
80
+ it('converts a click into screen points', () => {
81
+ expect(toSkillInput({ action: 'left_click', coordinate: [640, 400] }, factor)).toEqual({
82
+ action: 'click', x: 864, y: 540, button: 'left',
83
+ });
84
+ });
85
+
86
+ it('maps the three click flavours onto the skill button names', () => {
87
+ expect(toSkillInput({ action: 'right_click', coordinate: [10, 10] }, 1)).toMatchObject({ button: 'right' });
88
+ expect(toSkillInput({ action: 'double_click', coordinate: [10, 10] }, 1)).toMatchObject({ button: 'double' });
89
+ });
90
+
91
+ it('converts both ends of a drag', () => {
92
+ expect(toSkillInput({ action: 'left_click_drag', start_coordinate: [100, 100], coordinate: [200, 200] }, 2))
93
+ .toEqual({ action: 'drag', fromX: 200, fromY: 200, toX: 400, toY: 400 });
94
+ });
95
+
96
+ it('says plainly when an action is not supported instead of doing something else', () => {
97
+ // A model told "not supported" picks another route; one told "done"
98
+ // builds on a click that never happened.
99
+ expect(toSkillInput({ action: 'middle_click', coordinate: [1, 1] }, 1)).toMatchObject({ error: expect.stringContaining('not supported') });
100
+ expect(toSkillInput({ action: 'triple_click', coordinate: [1, 1] }, 1)).toMatchObject({ error: expect.stringContaining('not supported') });
101
+ });
102
+
103
+ it('refuses an action whose arguments are missing, naming what it needs', () => {
104
+ expect(toSkillInput({ action: 'left_click' }, 1)).toMatchObject({ error: expect.stringContaining('coordinate') });
105
+ expect(toSkillInput({ action: 'key' }, 1)).toMatchObject({ error: expect.stringContaining('text') });
106
+ expect(toSkillInput({ action: 'click_ref' }, 1)).toMatchObject({ error: expect.stringContaining('ref') });
107
+ expect(toSkillInput({ action: 'fill_ref', ref: '@e1' }, 1)).toMatchObject({ error: expect.stringContaining('text') });
108
+ expect(toSkillInput({ action: 'wait_for' }, 1)).toMatchObject({ error: expect.stringContaining('app') });
109
+ });
110
+
111
+ it('passes element actions through by ref, with no coordinates involved', () => {
112
+ expect(toSkillInput({ action: 'click_ref', ref: '@e12' }, 99)).toEqual({ action: 'click-ref', ref: '@e12' });
113
+ expect(toSkillInput({ action: 'fill_ref', ref: '@e7', text: 'hi' }, 99)).toEqual({ action: 'fill-ref', ref: '@e7', text: 'hi' });
114
+ });
115
+
116
+ it('turns `wait` into waiting for the screen to settle', () => {
117
+ expect(toSkillInput({ action: 'wait', duration: 2.5 }, 1)).toEqual({ action: 'wait-for', idle: true, timeoutMs: 2500 });
118
+ });
119
+
120
+ it('defaults a scroll rather than refusing it', () => {
121
+ expect(toSkillInput({ action: 'scroll', coordinate: [100, 100] }, 1))
122
+ .toMatchObject({ action: 'scroll', direction: 'down', amount: 3 });
123
+ });
124
+ });
125
+
126
+ describe('parseSkillOutput', () => {
127
+ it('reads the JSON line even when a shell warning came first', () => {
128
+ const raw = '{"warning":"CREWLY_SESSION_NAME is not set"}\n{"success":true,"action":"click"}';
129
+ expect(parseSkillOutput(raw)).toEqual({ success: true, action: 'click' });
130
+ });
131
+
132
+ it('reads jq pretty-printed output, which spans lines', () => {
133
+ expect(parseSkillOutput('{\n "success": false,\n "reason": "screen_locked"\n}'))
134
+ .toEqual({ success: false, reason: 'screen_locked' });
135
+ });
136
+
137
+ it('describes the failure rather than throwing when there is no JSON', () => {
138
+ expect(parseSkillOutput('bash: command not found')).toMatchObject({ success: false, reason: 'unparsable' });
139
+ expect(parseSkillOutput('')).toMatchObject({ success: false, reason: 'unparsable' });
140
+ });
141
+ });
142
+
143
+ describe('computer tool', () => {
144
+ it('routes every action through the skill rather than touching the mouse itself', async () => {
145
+ const { calls, runSkill, readImage } = recorder();
146
+ const tool = createComputerTool({ runSkill, readImage });
147
+ await tool.execute({ action: 'left_click', coordinate: [640, 400] });
148
+ // displays (to learn the scale), the click, then the screenshot.
149
+ expect(calls.map((c) => c['action'])).toEqual(['displays', 'click', 'screenshot']);
150
+ expect(calls[1]).toMatchObject({ x: 864, y: 540 });
151
+ });
152
+
153
+ it('returns the new screenshot with the action, so one call shows its own result', async () => {
154
+ const { runSkill, readImage } = recorder();
155
+ const tool = createComputerTool({ runSkill, readImage });
156
+ const out = (await tool.execute({ action: 'left_click', coordinate: [10, 10] })) as Record<string, unknown>;
157
+ expect(out['type']).toBe('image');
158
+ expect(out['data']).toBe('aGVsbG8=');
159
+ expect(out['screen']).toMatchObject({ width: 1280 });
160
+ expect(String(out['note'])).toContain('1280');
161
+ });
162
+
163
+ it('asks for the screenshot at the width the model is told to use', async () => {
164
+ const { calls, runSkill, readImage } = recorder();
165
+ const tool = createComputerTool({ runSkill, readImage });
166
+ await tool.execute({ action: 'screenshot' });
167
+ const shot = calls.find((c) => c['action'] === 'screenshot');
168
+ // Without this the image and the coordinate space drift apart and every
169
+ // click lands somewhere else.
170
+ expect(shot).toMatchObject({ maxWidth: 1280 });
171
+ });
172
+
173
+ it('does not take a screenshot after an action that changed nothing', async () => {
174
+ const { calls, runSkill, readImage } = recorder({ snapshot: { success: true, elements: [] } });
175
+ const tool = createComputerTool({ runSkill, readImage });
176
+ await tool.execute({ action: 'snapshot', app: 'Finder' });
177
+ expect(calls.map((c) => c['action'])).toEqual(['displays', 'snapshot']);
178
+ });
179
+
180
+ it('passes a rail refusal straight back, with no screenshot over it', async () => {
181
+ const refusal = { success: false, reason: 'screen_locked', message: 'The screen is locked.' };
182
+ const { calls, runSkill, readImage } = recorder({ click: refusal });
183
+ const tool = createComputerTool({ runSkill, readImage });
184
+ const out = (await tool.execute({ action: 'left_click', coordinate: [1, 1] })) as Record<string, unknown>;
185
+ // The rails phrase their own reasons; a screenshot of a screen it could
186
+ // not act on adds nothing.
187
+ expect(out).toMatchObject({ reason: 'screen_locked' });
188
+ expect(out['type']).toBeUndefined();
189
+ expect(calls.some((c) => c['action'] === 'screenshot')).toBe(false);
190
+ });
191
+
192
+ it('refuses bad arguments before running anything', async () => {
193
+ const { calls, runSkill, readImage } = recorder();
194
+ const tool = createComputerTool({ runSkill, readImage });
195
+ const out = (await tool.execute({ action: 'left_click' })) as Record<string, unknown>;
196
+ expect(out).toMatchObject({ success: false, reason: 'bad_arguments' });
197
+ expect(calls.map((c) => c['action'])).toEqual(['displays']);
198
+ });
199
+
200
+ it('re-reads the display each call, so a resolution change does not skew every click', async () => {
201
+ const { runSkill, readImage } = recorder();
202
+ const tool = createComputerTool({ runSkill, readImage });
203
+ await tool.execute({ action: 'mouse_move', coordinate: [100, 100] });
204
+ await tool.execute({ action: 'mouse_move', coordinate: [100, 100] });
205
+ expect(runSkill.mock.calls.filter(([c]) => (c as Record<string, unknown>)['action'] === 'displays')).toHaveLength(2);
206
+ });
207
+
208
+ it('offers the element actions alongside the Anthropic vocabulary', () => {
209
+ const tool = createComputerTool();
210
+ const schema = tool.inputSchema as unknown as { shape: { action: { options: string[] } } };
211
+ const actions = schema.shape.action.options;
212
+ for (const anthropic of ['screenshot', 'left_click', 'key', 'type', 'scroll', 'wait']) {
213
+ expect(actions).toContain(anthropic);
214
+ }
215
+ for (const crewly of ['snapshot', 'click_ref', 'fill_ref', 'wait_for']) {
216
+ expect(actions).toContain(crewly);
217
+ }
218
+ });
219
+ });
@@ -0,0 +1,405 @@
1
+ /**
2
+ * `computer` tool — desktop control for the in-process runtime.
3
+ *
4
+ * Phase 3 of docs/research/computer-use-capability-assessment.md. The
5
+ * computer-use skill could already drive the desktop, but an in-process agent
6
+ * reached it the long way round: `bash_exec` the script, read the JSON, then
7
+ * `read_file` the screenshot it wrote. Two tool calls per step, and a weak
8
+ * model had to remember a shell invocation and a file path to get one look at
9
+ * the screen.
10
+ *
11
+ * This is one call that returns the result *and* the new screenshot, which is
12
+ * what every published computer-use agent expects.
13
+ *
14
+ * Two deliberate choices:
15
+ *
16
+ * The action names and the coordinate convention match Anthropic's
17
+ * `computer_20250124` tool. Claude-family models have seen that shape in
18
+ * training and need no instruction; for every other model there is a large
19
+ * body of public examples to imitate. Inventing our own names would cost
20
+ * accuracy for nothing. Crewly's element-level actions (`snapshot`,
21
+ * `click_ref`, `fill_ref`, `wait_for`) are added alongside — they have no
22
+ * equivalent in that spec and are the ones a weak model should reach for
23
+ * first, because naming `@e12` cannot miss the way a coordinate can.
24
+ *
25
+ * Nothing here talks to the mouse. Every action shells out to the
26
+ * computer-use skill, so the safety rails — permissions, stop switch, desktop
27
+ * lock, destructive-key and password-field refusals, the audit log — apply
28
+ * exactly once, in one place, to every runtime. A second implementation here
29
+ * would be a second thing to keep in step, and the rails are the part that
30
+ * must not drift.
31
+ *
32
+ * @module runtime/computer.tool
33
+ */
34
+
35
+ import { spawn } from 'child_process';
36
+ import { promises as fs } from 'fs';
37
+ import * as os from 'os';
38
+ import * as path from 'path';
39
+ import { z } from 'zod';
40
+ import type { ToolDefinition } from './types.js';
41
+
42
+ /**
43
+ * Width every screenshot is scaled to before the model sees it.
44
+ *
45
+ * Anthropic's guidance, and the reason is worth keeping in mind: accuracy
46
+ * falls off above roughly this width because the image is downsampled before
47
+ * the model ever sees it, and a model reasoning in the original coordinate
48
+ * space then points at the wrong place. The model works in scaled
49
+ * coordinates; this tool converts them back.
50
+ */
51
+ const TARGET_WIDTH = 1280;
52
+
53
+ /** Actions that move or type, and so return a fresh screenshot afterwards. */
54
+ const MUTATING = new Set([
55
+ 'left_click', 'right_click', 'middle_click', 'double_click', 'triple_click',
56
+ 'left_click_drag', 'mouse_move', 'key', 'type', 'scroll', 'click_ref', 'fill_ref',
57
+ ]);
58
+
59
+ /** How long an action may take before the tool gives up on the skill. */
60
+ const DEFAULT_TIMEOUT_MS = 60_000;
61
+
62
+ /** Injectable IO, for tests. */
63
+ export interface ComputerToolDeps {
64
+ /** Run the skill and return its stdout. */
65
+ runSkill?: (input: Record<string, unknown>) => Promise<string>;
66
+ /** Read a screenshot file as base64. */
67
+ readImage?: (file: string) => Promise<{ data: string; bytes: number }>;
68
+ /** Crewly install directory, holding config/skills. */
69
+ installDir?: string;
70
+ }
71
+
72
+ /** One screen, as the skill reports it. */
73
+ interface DisplayInfo {
74
+ frame: [number, number, number, number];
75
+ scale: number;
76
+ main: boolean;
77
+ }
78
+
79
+ /**
80
+ * Run the computer-use skill with a JSON payload.
81
+ *
82
+ * Failures come back as the skill's own JSON where possible: its refusals
83
+ * (`permission_required`, `screen_locked`, `destructive_blocked`…) already
84
+ * say what to do about them, and rewording them here would only blur that.
85
+ *
86
+ * @param input - The skill's JSON input
87
+ * @param deps - Injected IO
88
+ * @returns Parsed skill output
89
+ */
90
+ async function runSkill(input: Record<string, unknown>, deps: ComputerToolDeps): Promise<Record<string, unknown>> {
91
+ if (deps.runSkill) {
92
+ const raw = await deps.runSkill(input);
93
+ return parseSkillOutput(raw);
94
+ }
95
+
96
+ const installDir = deps.installDir ?? process.env['CREWLY_INSTALL_DIR'] ?? process.cwd();
97
+ const script = path.join(installDir, 'config', 'skills', 'agent', 'computer-use', 'execute.sh');
98
+
99
+ return new Promise((resolve) => {
100
+ const child = spawn('bash', [script, JSON.stringify(input)], {
101
+ env: { ...process.env },
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ });
104
+ let stdout = '';
105
+ let stderr = '';
106
+ const timer = setTimeout(() => {
107
+ child.kill('SIGKILL');
108
+ resolve({
109
+ success: false,
110
+ reason: 'timeout',
111
+ message: `The desktop action did not finish within ${DEFAULT_TIMEOUT_MS / 1000}s.`,
112
+ });
113
+ }, DEFAULT_TIMEOUT_MS);
114
+
115
+ child.stdout.on('data', (chunk) => { stdout += String(chunk); });
116
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
117
+ child.on('error', (err) => {
118
+ clearTimeout(timer);
119
+ resolve({ success: false, reason: 'skill_unavailable', message: err.message, script });
120
+ });
121
+ child.on('close', () => {
122
+ clearTimeout(timer);
123
+ resolve(parseSkillOutput(stdout || stderr));
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Parse the skill's stdout.
130
+ *
131
+ * The skill prints one JSON object, but a shell warning can precede it (the
132
+ * shared runner warns when CREWLY_SESSION_NAME is unset), so the last
133
+ * JSON-looking line wins.
134
+ *
135
+ * @param raw - Captured output
136
+ * @returns The parsed object, or a described failure when there is none
137
+ */
138
+ export function parseSkillOutput(raw: string): Record<string, unknown> {
139
+ const lines = (raw ?? '').trim().split('\n').filter((l) => l.trim());
140
+ for (let i = lines.length - 1; i >= 0; i--) {
141
+ const line = lines[i]!.trim();
142
+ if (!line.startsWith('{')) continue;
143
+ try {
144
+ return JSON.parse(line) as Record<string, unknown>;
145
+ } catch {
146
+ // Not the JSON line after all — keep looking backwards.
147
+ }
148
+ }
149
+ // Multi-line pretty-printed JSON (jq's default) is one object across lines.
150
+ const joined = lines.join('\n');
151
+ const start = joined.indexOf('{');
152
+ if (start >= 0) {
153
+ try {
154
+ return JSON.parse(joined.slice(start)) as Record<string, unknown>;
155
+ } catch {
156
+ // Fall through to the described failure.
157
+ }
158
+ }
159
+ return { success: false, reason: 'unparsable', message: raw?.slice(0, 500) || 'The skill produced no output.' };
160
+ }
161
+
162
+ /**
163
+ * The scale between the coordinates the model uses and real screen points.
164
+ *
165
+ * @param displays - What the skill reported
166
+ * @returns Factor to multiply model coordinates by, and the scaled size
167
+ */
168
+ export function scaleFor(displays: DisplayInfo[]): { factor: number; width: number; height: number; screen: [number, number] } {
169
+ const main = displays.find((d) => d.main) ?? displays[0];
170
+ const [, , w, h] = main?.frame ?? [0, 0, TARGET_WIDTH, 800];
171
+ // Never scale up: a small screen is already easier to point at than a large
172
+ // one, and enlarging it would invent precision the model does not have.
173
+ const factor = w > TARGET_WIDTH ? w / TARGET_WIDTH : 1;
174
+ return {
175
+ factor,
176
+ width: Math.round(w / factor),
177
+ height: Math.round(h / factor),
178
+ screen: [w, h],
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Convert a model coordinate into a screen point.
184
+ *
185
+ * @param value - Coordinate in the scaled space the model sees
186
+ * @param factor - From {@link scaleFor}
187
+ * @returns Screen point
188
+ */
189
+ export function toScreen(value: number, factor: number): number {
190
+ return Math.round(value * factor);
191
+ }
192
+
193
+ /**
194
+ * Take a screenshot scaled for the model.
195
+ *
196
+ * @param deps - Injected IO
197
+ * @returns Image payload, or null when the screenshot failed
198
+ */
199
+ async function capture(
200
+ deps: ComputerToolDeps,
201
+ ): Promise<{ data: string; bytes: number; file: string } | null> {
202
+ const file = path.join(os.tmpdir(), `crewly-computer-${process.pid}-${Date.now()}.png`);
203
+ // maxWidth is what makes the screenshot match the coordinate space the
204
+ // model is told to use; without it the two drift and every click is off.
205
+ const shot = await runSkill(
206
+ { action: 'screenshot', output: file, maxWidth: Math.round(TARGET_WIDTH) },
207
+ deps,
208
+ );
209
+ const written = (shot['path'] as string) ?? file;
210
+ try {
211
+ if (deps.readImage) {
212
+ const { data, bytes } = await deps.readImage(written);
213
+ return { data, bytes, file: written };
214
+ }
215
+ const buffer = await fs.readFile(written);
216
+ await fs.unlink(written).catch(() => undefined);
217
+ return { data: buffer.toString('base64'), bytes: buffer.length, file: written };
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /** Arguments the model may send. */
224
+ const computerSchema = z.object({
225
+ action: z.enum([
226
+ // Anthropic computer_20250124 vocabulary.
227
+ 'screenshot', 'left_click', 'right_click', 'middle_click', 'double_click',
228
+ 'triple_click', 'left_click_drag', 'mouse_move', 'key', 'type', 'scroll',
229
+ 'wait', 'cursor_position',
230
+ // Crewly's element-level additions.
231
+ 'snapshot', 'click_ref', 'fill_ref', 'wait_for', 'ocr', 'displays',
232
+ ]).describe('What to do. Prefer snapshot + click_ref/fill_ref over coordinates when the app exposes elements.'),
233
+ coordinate: z.array(z.number()).length(2).optional()
234
+ .describe('[x, y] in the screenshot you were shown, not screen pixels. The tool converts.'),
235
+ start_coordinate: z.array(z.number()).length(2).optional()
236
+ .describe('[x, y] to drag from, for left_click_drag.'),
237
+ text: z.string().optional()
238
+ .describe('Text to type, the key combo for `key` (e.g. "command+s"), or the value for fill_ref.'),
239
+ ref: z.string().optional().describe('Element reference from a snapshot, e.g. "@e12".'),
240
+ app: z.string().optional().describe('Application to snapshot or wait for; defaults to the frontmost.'),
241
+ scroll_direction: z.enum(['up', 'down', 'left', 'right']).optional(),
242
+ scroll_amount: z.number().optional().describe('Scroll clicks; defaults to 3.'),
243
+ duration: z.number().optional().describe('Seconds to wait, for `wait`.'),
244
+ });
245
+
246
+ /**
247
+ * Translate the tool's arguments into the skill's own input.
248
+ *
249
+ * @param args - Validated tool arguments
250
+ * @param factor - Coordinate scale
251
+ * @returns The skill payload, or a refusal when the arguments do not fit
252
+ */
253
+ export function toSkillInput(
254
+ args: z.infer<typeof computerSchema>,
255
+ factor: number,
256
+ ): Record<string, unknown> | { error: string } {
257
+ const point = (pair?: number[]) =>
258
+ pair ? { x: toScreen(pair[0]!, factor), y: toScreen(pair[1]!, factor) } : null;
259
+
260
+ switch (args.action) {
261
+ case 'screenshot':
262
+ return { action: 'screenshot' };
263
+ case 'displays':
264
+ return { action: 'displays' };
265
+ case 'cursor_position':
266
+ // The skill has no cursor read; a screenshot answers the same question
267
+ // and is what the model will ask for next anyway.
268
+ return { action: 'screenshot' };
269
+
270
+ case 'left_click':
271
+ case 'right_click':
272
+ case 'double_click': {
273
+ const p = point(args.coordinate);
274
+ if (!p) return { error: `${args.action} needs a coordinate.` };
275
+ const button = args.action === 'right_click' ? 'right' : args.action === 'double_click' ? 'double' : 'left';
276
+ return { action: 'click', ...p, button };
277
+ }
278
+ case 'middle_click':
279
+ case 'triple_click': {
280
+ // Neither exists in the skill. Saying so beats silently doing something
281
+ // else: a model told "not supported" picks another route, one told
282
+ // "done" builds on a click that never happened.
283
+ return { error: `${args.action} is not supported on this platform. Use left_click, or select the text another way.` };
284
+ }
285
+ case 'mouse_move': {
286
+ const p = point(args.coordinate);
287
+ if (!p) return { error: 'mouse_move needs a coordinate.' };
288
+ return { action: 'move', ...p };
289
+ }
290
+ case 'left_click_drag': {
291
+ const from = point(args.start_coordinate);
292
+ const to = point(args.coordinate);
293
+ if (!from || !to) return { error: 'left_click_drag needs start_coordinate and coordinate.' };
294
+ return { action: 'drag', fromX: from.x, fromY: from.y, toX: to.x, toY: to.y };
295
+ }
296
+ case 'key':
297
+ if (!args.text) return { error: 'key needs `text`, e.g. "command+s".' };
298
+ return { action: 'key', key: args.text };
299
+ case 'type':
300
+ if (!args.text) return { error: 'type needs `text`.' };
301
+ return { action: 'type', text: args.text };
302
+ case 'scroll': {
303
+ const p = point(args.coordinate);
304
+ return {
305
+ action: 'scroll',
306
+ ...(p ?? {}),
307
+ direction: args.scroll_direction ?? 'down',
308
+ amount: args.scroll_amount ?? 3,
309
+ };
310
+ }
311
+
312
+ case 'snapshot':
313
+ return { action: 'snapshot', ...(args.app ? { app: args.app } : {}) };
314
+ case 'click_ref':
315
+ if (!args.ref) return { error: 'click_ref needs `ref`, e.g. "@e12" from a snapshot.' };
316
+ return { action: 'click-ref', ref: args.ref };
317
+ case 'fill_ref':
318
+ if (!args.ref || args.text === undefined) return { error: 'fill_ref needs `ref` and `text`.' };
319
+ return { action: 'fill-ref', ref: args.ref, text: args.text };
320
+ case 'ocr':
321
+ return { action: 'ocr' };
322
+ case 'wait_for':
323
+ if (!args.app && !args.ref && !args.text) {
324
+ return { error: 'wait_for needs one of `app`, `ref` or `text`.' };
325
+ }
326
+ return {
327
+ action: 'wait-for',
328
+ ...(args.app ? { app: args.app } : {}),
329
+ ...(args.ref ? { ref: args.ref } : {}),
330
+ ...(args.text ? { text: args.text } : {}),
331
+ };
332
+ case 'wait':
333
+ return { action: 'wait-for', idle: true, timeoutMs: Math.round((args.duration ?? 1) * 1000) };
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Build the `computer` tool.
339
+ *
340
+ * @param deps - Injected IO for tests
341
+ * @returns The tool definition
342
+ *
343
+ * @example
344
+ * ```ts
345
+ * const tools = { computer: createComputerTool() };
346
+ * ```
347
+ */
348
+ export function createComputerTool(deps: ComputerToolDeps = {}): ToolDefinition {
349
+ return {
350
+ description:
351
+ 'Control this Mac: look at the screen and act on it. ' +
352
+ 'Prefer `snapshot` then `click_ref`/`fill_ref` — naming an element cannot miss the way a coordinate can, ' +
353
+ 'and it keeps working when the window moves. Fall back to coordinates for canvases and custom-drawn UI. ' +
354
+ 'Coordinates are in the screenshot you were shown, not screen pixels. ' +
355
+ 'Destructive key combos, password fields and credential apps are refused, and the owner can stop everything at any time.',
356
+ inputSchema: computerSchema,
357
+ sensitivity: 'destructive',
358
+ execute: async (rawArgs) => {
359
+ const args = rawArgs as z.infer<typeof computerSchema>;
360
+
361
+ // The scale has to come from the live display: the owner may have
362
+ // changed resolution or moved to another screen since the last call.
363
+ const displayResult = await runSkill({ action: 'displays' }, deps);
364
+ const displays = (displayResult['displays'] as DisplayInfo[] | undefined) ?? [];
365
+ const scale = scaleFor(displays);
366
+
367
+ const payload = toSkillInput(args, scale.factor);
368
+ if ('error' in payload) {
369
+ return { success: false, reason: 'bad_arguments', message: payload.error };
370
+ }
371
+
372
+ // A bare screenshot is taken once, by the capture step below. Running
373
+ // the skill's screenshot here as well would shoot the screen twice for
374
+ // one request — slow, and the two images could even differ.
375
+ const result = args.action === 'screenshot'
376
+ ? { success: true }
377
+ : await runSkill(payload, deps);
378
+
379
+ // A refusal is returned as it stands. The rails phrase their own
380
+ // reasons and tell the agent what to do; a screenshot alongside would
381
+ // just be the same screen it could not act on.
382
+ if (result['success'] === false) return result;
383
+
384
+ const out: Record<string, unknown> = {
385
+ ...result,
386
+ action: args.action,
387
+ screen: { width: scale.width, height: scale.height, actual: scale.screen },
388
+ };
389
+
390
+ // One call, one look. Showing the result of an action is what lets a
391
+ // model check its own work instead of assuming the click landed.
392
+ if (MUTATING.has(args.action) || args.action === 'screenshot') {
393
+ const image = await capture(deps);
394
+ if (image) {
395
+ out['type'] = 'image';
396
+ out['mimeType'] = 'image/png';
397
+ out['data'] = image.data;
398
+ out['sizeBytes'] = image.bytes;
399
+ out['note'] = `Screenshot is ${scale.width}×${scale.height}; give coordinates in that space.`;
400
+ }
401
+ }
402
+ return out;
403
+ },
404
+ };
405
+ }