@gakim-digital/dexter-bridge 0.5.21 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -35
- package/package.json +19 -5
- package/src/agent.js +1351 -331
- package/src/agentOutput.js +209 -0
- package/src/api.js +48 -1
- package/src/cli.js +267 -39
- package/src/config.js +30 -7
- package/src/framerAgentTools.js +1108 -0
- package/src/harnessMcpServer.js +240 -0
- package/src/harnessTools.js +548 -0
- package/src/logger.js +1 -1
- package/src/nativeSkills.js +295 -0
- package/src/outcomeWorkspace.js +351 -0
- package/src/protocol.js +239 -0
- package/src/providers/acp.js +241 -0
- package/src/providers/codexAppServer.js +1050 -156
- package/src/providers/codexStructuredOutput.js +243 -16
- package/src/providers/directByok.js +197 -0
- package/src/providers/index.js +33 -7
- package/src/providers/openCode.js +607 -0
- package/src/runtimeProfiles.js +284 -0
- package/src/providers/claudeAgentSdk.js +0 -507
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
|
8
|
+
const SESSION_TIMEOUT_MS = 10 * 60_000;
|
|
9
|
+
const AUTHORIZATION_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
const AUTHORIZATION_POLL_MS = 2_000;
|
|
11
|
+
const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,200}$/;
|
|
12
|
+
const PAGE_PATH_PATTERN = /^\/[^\0\r\n]{0,499}$/;
|
|
13
|
+
const UNSAFE_EXEC_PATTERN =
|
|
14
|
+
/\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
|
|
15
|
+
const MUTATING_EXEC_PATTERN =
|
|
16
|
+
/\.(?:set|add|create|remove|delete|update|insert|move|clone|duplicate|upload|publish|deploy|switch|merge|rename|join|leave|apply)[A-Za-z0-9_]*\s*\(/;
|
|
17
|
+
|
|
18
|
+
const EMPTY_SCHEMA = {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {},
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const FRAMER_AGENT_TOOL_DEFINITIONS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'progress_update',
|
|
27
|
+
description:
|
|
28
|
+
'Tell the user what you are doing and why it matters in one or two natural first-person sentences. Call this before the first Framer inspection or edit and at meaningful phase changes.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
message: { type: 'string', minLength: 1, maxLength: 500 },
|
|
33
|
+
},
|
|
34
|
+
required: ['message'],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'framer_instructions',
|
|
40
|
+
description:
|
|
41
|
+
'Load Framer’s official agent command reference. Call this before the first canvas change unless the current session already established the command syntax.',
|
|
42
|
+
inputSchema: EMPTY_SCHEMA,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'framer_context',
|
|
46
|
+
description:
|
|
47
|
+
'Read current project context, including pages, components, styles, fonts, CMS collections, and the active branch.',
|
|
48
|
+
inputSchema: EMPTY_SCHEMA,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'framer_read_project',
|
|
52
|
+
description:
|
|
53
|
+
'Run Framer Agent readProject queries against the connected project. Use this for focused canvas inspection and screenshots.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
queries: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
minItems: 1,
|
|
60
|
+
maxItems: 50,
|
|
61
|
+
items: { type: 'object', additionalProperties: true },
|
|
62
|
+
},
|
|
63
|
+
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
64
|
+
},
|
|
65
|
+
required: ['queries'],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'framer_apply_changes',
|
|
71
|
+
description:
|
|
72
|
+
'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
changes: { type: 'string', minLength: 1, maxLength: 250_000 },
|
|
77
|
+
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
78
|
+
},
|
|
79
|
+
required: ['changes'],
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'framer_read',
|
|
85
|
+
description:
|
|
86
|
+
'Execute read-only JavaScript with the official Framer Agent connection for capabilities not covered by readProject.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
code: { type: 'string', minLength: 1, maxLength: 200_000 },
|
|
91
|
+
},
|
|
92
|
+
required: ['code'],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'framer_write',
|
|
98
|
+
description:
|
|
99
|
+
'Execute project-scoped JavaScript with the official Framer Agent connection for mutations not covered by applyChanges, such as code files, localization, redirects, assets, CMS, or branch operations. Publishing is disabled unless Dexter explicitly authorizes it.',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
code: { type: 'string', minLength: 1, maxLength: 200_000 },
|
|
104
|
+
},
|
|
105
|
+
required: ['code'],
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const TOOL_BY_NAME = new Map(
|
|
112
|
+
FRAMER_AGENT_TOOL_DEFINITIONS.map((definition) => [
|
|
113
|
+
definition.name,
|
|
114
|
+
definition,
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
117
|
+
const sessionsByProject = new Map();
|
|
118
|
+
let hostedSetupPromise = null;
|
|
119
|
+
|
|
120
|
+
function isRecord(value) {
|
|
121
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toolError(code, message, statusCode = 400) {
|
|
125
|
+
return Object.assign(new Error(message), { code, statusCode });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function boundedText(value, maximum) {
|
|
129
|
+
const text = String(value || '');
|
|
130
|
+
if (Buffer.byteLength(text, 'utf8') <= maximum) return text;
|
|
131
|
+
return `${text.slice(0, maximum)}\n...[output clipped]`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeProjectId(value) {
|
|
135
|
+
const projectId = String(value || '').trim();
|
|
136
|
+
if (!PROJECT_ID_PATTERN.test(projectId)) {
|
|
137
|
+
throw toolError(
|
|
138
|
+
'FRAMER_AGENT_PROJECT_INVALID',
|
|
139
|
+
'The Framer project identifier is missing or invalid.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return projectId;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizePagePath(value) {
|
|
146
|
+
const pagePath = String(value || '/').trim() || '/';
|
|
147
|
+
if (!PAGE_PATH_PATTERN.test(pagePath)) {
|
|
148
|
+
throw toolError(
|
|
149
|
+
'FRAMER_AGENT_PAGE_PATH_INVALID',
|
|
150
|
+
'The Framer page path must start with "/" and contain no control characters.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return pagePath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function uniqueStrings(values, maximum = 500) {
|
|
157
|
+
return Array.from(
|
|
158
|
+
new Set(
|
|
159
|
+
values
|
|
160
|
+
.filter((value) => typeof value === 'string')
|
|
161
|
+
.map((value) => value.trim())
|
|
162
|
+
.filter(Boolean),
|
|
163
|
+
),
|
|
164
|
+
).slice(0, maximum);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function authoritativeNodeIdsFromAssignment(assignment) {
|
|
168
|
+
const context = isRecord(assignment?.context) ? assignment.context : {};
|
|
169
|
+
const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
|
|
170
|
+
return uniqueStrings([
|
|
171
|
+
context.activePageId,
|
|
172
|
+
...(Array.isArray(context.selectedNodeIds)
|
|
173
|
+
? context.selectedNodeIds
|
|
174
|
+
: []),
|
|
175
|
+
grant.activePageId,
|
|
176
|
+
...(Array.isArray(grant.selectedNodeIds)
|
|
177
|
+
? grant.selectedNodeIds
|
|
178
|
+
: []),
|
|
179
|
+
...(Array.isArray(grant.contextNodeIds)
|
|
180
|
+
? grant.contextNodeIds
|
|
181
|
+
: []),
|
|
182
|
+
...(Array.isArray(grant.writableNodeIds)
|
|
183
|
+
? grant.writableNodeIds
|
|
184
|
+
: []),
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateCode(value, { write = false, allowPublishing = false } = {}) {
|
|
189
|
+
const code = String(value || '').trim();
|
|
190
|
+
if (!code) {
|
|
191
|
+
throw toolError(
|
|
192
|
+
'FRAMER_AGENT_CODE_REQUIRED',
|
|
193
|
+
'Framer Agent code is required.',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (UNSAFE_EXEC_PATTERN.test(code)) {
|
|
197
|
+
throw toolError(
|
|
198
|
+
'FRAMER_AGENT_CODE_UNSAFE',
|
|
199
|
+
'Framer Agent code may use only the provided framer, state, and console globals.',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!write && MUTATING_EXEC_PATTERN.test(code)) {
|
|
203
|
+
throw toolError(
|
|
204
|
+
'FRAMER_AGENT_READ_ONLY_VIOLATION',
|
|
205
|
+
'Use framer_write or framer_apply_changes for project mutations.',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
!allowPublishing &&
|
|
210
|
+
/\.(?:publish|deploy)\s*\(/.test(code)
|
|
211
|
+
) {
|
|
212
|
+
throw toolError(
|
|
213
|
+
'FRAMER_AGENT_PUBLISH_NOT_AUTHORIZED',
|
|
214
|
+
'Publishing is not authorized for this Dexter run.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return code;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractJsonArray(value) {
|
|
221
|
+
const text = String(value || '').trim();
|
|
222
|
+
const start = text.indexOf('[');
|
|
223
|
+
const end = text.lastIndexOf(']');
|
|
224
|
+
if (start < 0 || end <= start) return [];
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
227
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
228
|
+
} catch {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function abortError() {
|
|
234
|
+
return Object.assign(new Error('The Framer authorization was cancelled.'), {
|
|
235
|
+
name: 'AbortError',
|
|
236
|
+
code: 'ABORT_ERR',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function wait(delayMs, signal) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
if (signal?.aborted) {
|
|
243
|
+
reject(abortError());
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const onAbort = () => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
reject(abortError());
|
|
249
|
+
};
|
|
250
|
+
const timer = setTimeout(() => {
|
|
251
|
+
signal?.removeEventListener('abort', onAbort);
|
|
252
|
+
resolve();
|
|
253
|
+
}, Math.max(0, delayMs));
|
|
254
|
+
timer.unref?.();
|
|
255
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function authorizationUrl(value) {
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = new URL(String(value || ''));
|
|
263
|
+
} catch {
|
|
264
|
+
throw toolError(
|
|
265
|
+
'FRAMER_AGENT_AUTHORIZATION_URL_INVALID',
|
|
266
|
+
'Dexter received an invalid Framer authorization URL.',
|
|
267
|
+
502,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
271
|
+
const trusted =
|
|
272
|
+
hostname === 'framer.com'
|
|
273
|
+
|| hostname.endsWith('.framer.com')
|
|
274
|
+
|| hostname === 'framerlocal.com'
|
|
275
|
+
|| hostname.endsWith('.framerlocal.com');
|
|
276
|
+
if (
|
|
277
|
+
parsed.protocol !== 'https:'
|
|
278
|
+
|| parsed.username
|
|
279
|
+
|| parsed.password
|
|
280
|
+
|| !trusted
|
|
281
|
+
) {
|
|
282
|
+
throw toolError(
|
|
283
|
+
'FRAMER_AGENT_AUTHORIZATION_URL_INVALID',
|
|
284
|
+
'Dexter blocked an untrusted Framer authorization URL.',
|
|
285
|
+
502,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return parsed.toString();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function openFramerAuthorizationUrl(
|
|
292
|
+
value,
|
|
293
|
+
{
|
|
294
|
+
platform = process.platform,
|
|
295
|
+
spawnImpl = spawn,
|
|
296
|
+
} = {},
|
|
297
|
+
) {
|
|
298
|
+
const url = authorizationUrl(value);
|
|
299
|
+
const command =
|
|
300
|
+
platform === 'darwin'
|
|
301
|
+
? '/usr/bin/open'
|
|
302
|
+
: platform === 'win32'
|
|
303
|
+
? 'rundll32.exe'
|
|
304
|
+
: 'xdg-open';
|
|
305
|
+
const args =
|
|
306
|
+
platform === 'win32'
|
|
307
|
+
? ['url.dll,FileProtocolHandler', url]
|
|
308
|
+
: [url];
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
const child = spawnImpl(command, args, {
|
|
311
|
+
stdio: 'ignore',
|
|
312
|
+
detached: true,
|
|
313
|
+
windowsHide: true,
|
|
314
|
+
});
|
|
315
|
+
child.once?.('error', (error) => reject(error));
|
|
316
|
+
child.once?.('spawn', () => {
|
|
317
|
+
child.unref?.();
|
|
318
|
+
resolve(true);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function parseStructuredOutput(value) {
|
|
324
|
+
const text = String(value || '').trim();
|
|
325
|
+
if (!text) return null;
|
|
326
|
+
const candidates = [text];
|
|
327
|
+
const firstObject = text.indexOf('{');
|
|
328
|
+
const lastObject = text.lastIndexOf('}');
|
|
329
|
+
if (firstObject >= 0 && lastObject > firstObject) {
|
|
330
|
+
candidates.push(text.slice(firstObject, lastObject + 1));
|
|
331
|
+
}
|
|
332
|
+
const firstArray = text.indexOf('[');
|
|
333
|
+
const lastArray = text.lastIndexOf(']');
|
|
334
|
+
if (firstArray >= 0 && lastArray > firstArray) {
|
|
335
|
+
candidates.push(text.slice(firstArray, lastArray + 1));
|
|
336
|
+
}
|
|
337
|
+
for (const candidate of candidates) {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(candidate);
|
|
340
|
+
} catch {
|
|
341
|
+
// Keep the original text when the command intentionally prints prose.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseBranchChange(value) {
|
|
348
|
+
const match = String(value || '').match(
|
|
349
|
+
/\[FRAMER_BRANCH_CHANGE\]\s+title="([^"]*)"\s+id="([^"]*)"\s+url="([^"]*)"/,
|
|
350
|
+
);
|
|
351
|
+
return match
|
|
352
|
+
? { title: match[1], id: match[2], url: match[3] }
|
|
353
|
+
: null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function resolveFramerAgentCli() {
|
|
357
|
+
try {
|
|
358
|
+
return require.resolve('@framer/agent');
|
|
359
|
+
} catch {
|
|
360
|
+
throw toolError(
|
|
361
|
+
'FRAMER_AGENT_NOT_INSTALLED',
|
|
362
|
+
'The official Framer Agent runtime is missing. Update Dexter Bridge and reconnect.',
|
|
363
|
+
503,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function runFramerAgentCli(args, {
|
|
369
|
+
cwd = process.cwd(),
|
|
370
|
+
env = process.env,
|
|
371
|
+
input,
|
|
372
|
+
signal,
|
|
373
|
+
timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
374
|
+
onOutput,
|
|
375
|
+
cliPath = resolveFramerAgentCli(),
|
|
376
|
+
} = {}) {
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
const child = spawn(process.execPath, [cliPath, ...args], {
|
|
379
|
+
cwd,
|
|
380
|
+
env,
|
|
381
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
382
|
+
windowsHide: true,
|
|
383
|
+
signal,
|
|
384
|
+
});
|
|
385
|
+
let stdout = '';
|
|
386
|
+
let stderr = '';
|
|
387
|
+
let outputBytes = 0;
|
|
388
|
+
let settled = false;
|
|
389
|
+
let timer;
|
|
390
|
+
const finish = (callback, value) => {
|
|
391
|
+
if (settled) return;
|
|
392
|
+
settled = true;
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
callback(value);
|
|
395
|
+
};
|
|
396
|
+
const append = (channel, chunk) => {
|
|
397
|
+
const text = chunk.toString('utf8');
|
|
398
|
+
outputBytes += Buffer.byteLength(text, 'utf8');
|
|
399
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
400
|
+
child.kill('SIGTERM');
|
|
401
|
+
finish(
|
|
402
|
+
reject,
|
|
403
|
+
toolError(
|
|
404
|
+
'FRAMER_AGENT_OUTPUT_TOO_LARGE',
|
|
405
|
+
'The Framer Agent command returned too much output.',
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (channel === 'stdout') stdout += text;
|
|
411
|
+
else stderr += text;
|
|
412
|
+
onOutput?.({ channel, text });
|
|
413
|
+
};
|
|
414
|
+
child.stdout.on('data', (chunk) => append('stdout', chunk));
|
|
415
|
+
child.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
416
|
+
child.once('error', (error) => finish(reject, error));
|
|
417
|
+
child.once('close', (code, signal) => {
|
|
418
|
+
if (settled) return;
|
|
419
|
+
if (code === 0) {
|
|
420
|
+
finish(resolve, {
|
|
421
|
+
stdout: boundedText(stdout, MAX_OUTPUT_BYTES),
|
|
422
|
+
stderr: boundedText(stderr, MAX_OUTPUT_BYTES),
|
|
423
|
+
code,
|
|
424
|
+
signal,
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const message = boundedText(stderr || stdout, 8_000).trim();
|
|
429
|
+
finish(
|
|
430
|
+
reject,
|
|
431
|
+
toolError(
|
|
432
|
+
/authoriz|log in|login|api key/i.test(message)
|
|
433
|
+
? 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
|
|
434
|
+
: 'FRAMER_AGENT_COMMAND_FAILED',
|
|
435
|
+
message || `Framer Agent exited with code ${code}.`,
|
|
436
|
+
/authoriz|log in|login|api key/i.test(message) ? 409 : 500,
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
timer = setTimeout(() => {
|
|
441
|
+
child.kill('SIGTERM');
|
|
442
|
+
finish(
|
|
443
|
+
reject,
|
|
444
|
+
toolError(
|
|
445
|
+
'FRAMER_AGENT_TIMEOUT',
|
|
446
|
+
'The Framer Agent command timed out.',
|
|
447
|
+
504,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}, Math.max(1_000, Number(timeoutMs) || DEFAULT_COMMAND_TIMEOUT_MS));
|
|
451
|
+
timer.unref?.();
|
|
452
|
+
if (input !== undefined) child.stdin.end(String(input));
|
|
453
|
+
else child.stdin.end();
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function sessionIdFromOutput(value) {
|
|
458
|
+
return String(value || '')
|
|
459
|
+
.split(/\r?\n/)
|
|
460
|
+
.map((line) => line.trim())
|
|
461
|
+
.find(
|
|
462
|
+
(line) =>
|
|
463
|
+
line &&
|
|
464
|
+
!line.startsWith('[') &&
|
|
465
|
+
!/^framer external agents/i.test(line) &&
|
|
466
|
+
/^[A-Za-z0-9_-]{4,200}$/.test(line),
|
|
467
|
+
) || null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isAuthorizationFailure(error) {
|
|
471
|
+
return (
|
|
472
|
+
error?.code === 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
|
|
473
|
+
|| error?.code === 'FRAMER_AGENT_PROJECT_NOT_FOUND'
|
|
474
|
+
|| /authoriz|log in|login|api key|project not found|session expired/i.test(
|
|
475
|
+
String(error?.message || ''),
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function projectConnectionError(error) {
|
|
481
|
+
if (/project not found/i.test(String(error?.message || ''))) {
|
|
482
|
+
return toolError(
|
|
483
|
+
'FRAMER_AGENT_PROJECT_NOT_FOUND',
|
|
484
|
+
'Framer could not authorize this project. Make sure the browser is signed into an account with access to the open project, then reconnect it in Dexter.',
|
|
485
|
+
409,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return error;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function createFramerAgentToolRuntime({
|
|
492
|
+
assignment,
|
|
493
|
+
cwd,
|
|
494
|
+
env = process.env,
|
|
495
|
+
trace,
|
|
496
|
+
onActivity,
|
|
497
|
+
runCli = runFramerAgentCli,
|
|
498
|
+
authorizeProject,
|
|
499
|
+
openAuthorizationUrl = openFramerAuthorizationUrl,
|
|
500
|
+
authorizationTimeoutMs = AUTHORIZATION_TIMEOUT_MS,
|
|
501
|
+
} = {}) {
|
|
502
|
+
const projectId = normalizeProjectId(assignment?.context?.framerProjectId);
|
|
503
|
+
const requestedTools = Array.isArray(assignment?.toolProtocol?.tools)
|
|
504
|
+
? assignment.toolProtocol.tools
|
|
505
|
+
: FRAMER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name);
|
|
506
|
+
const allowedToolNames = new Set(
|
|
507
|
+
requestedTools.filter((name) => TOOL_BY_NAME.has(name)),
|
|
508
|
+
);
|
|
509
|
+
const definitions = FRAMER_AGENT_TOOL_DEFINITIONS.filter((tool) =>
|
|
510
|
+
allowedToolNames.has(tool.name),
|
|
511
|
+
);
|
|
512
|
+
const allowPublishing = assignment?.context?.permissions?.publish === true;
|
|
513
|
+
const activePagePath = normalizePagePath(
|
|
514
|
+
assignment?.context?.activePagePath,
|
|
515
|
+
);
|
|
516
|
+
const authoritativeNodeIds =
|
|
517
|
+
authoritativeNodeIdsFromAssignment(assignment);
|
|
518
|
+
let sessionId = null;
|
|
519
|
+
let sessionPromise = null;
|
|
520
|
+
let nodeValidationPromise = null;
|
|
521
|
+
let server = null;
|
|
522
|
+
let gateway = null;
|
|
523
|
+
let closed = false;
|
|
524
|
+
let serial = Promise.resolve();
|
|
525
|
+
let mutationCalls = 0;
|
|
526
|
+
let readCalls = 0;
|
|
527
|
+
let branchChange = null;
|
|
528
|
+
let lastInspectionAt = null;
|
|
529
|
+
let operationSequence = 0;
|
|
530
|
+
let lastMutationSequence = 0;
|
|
531
|
+
let lastInspectionSequence = 0;
|
|
532
|
+
const abortController = new AbortController();
|
|
533
|
+
|
|
534
|
+
const reportActivity = (event) => {
|
|
535
|
+
try {
|
|
536
|
+
void Promise.resolve(onActivity?.(event)).catch(() => undefined);
|
|
537
|
+
} catch {
|
|
538
|
+
// Observability must never interrupt a Framer operation.
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const executeCli = async (args, options = {}) => {
|
|
543
|
+
const result = await runCli(args, {
|
|
544
|
+
cwd,
|
|
545
|
+
env,
|
|
546
|
+
signal: abortController.signal,
|
|
547
|
+
...options,
|
|
548
|
+
});
|
|
549
|
+
const nextBranch = parseBranchChange(result.stdout);
|
|
550
|
+
if (nextBranch) branchChange = nextBranch;
|
|
551
|
+
return result;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const ensureHostedSetup = async () => {
|
|
555
|
+
if (env.DEXTER_HOSTED_FRAMER_AUTO_SETUP !== 'true') return;
|
|
556
|
+
if (!hostedSetupPromise) {
|
|
557
|
+
reportActivity({
|
|
558
|
+
kind: 'status_update',
|
|
559
|
+
source: 'framer-agent',
|
|
560
|
+
message: 'Preparing the Framer harness.',
|
|
561
|
+
});
|
|
562
|
+
hostedSetupPromise = executeCli(['setup'], {
|
|
563
|
+
timeoutMs: 30_000,
|
|
564
|
+
}).catch((error) => {
|
|
565
|
+
hostedSetupPromise = null;
|
|
566
|
+
throw error;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
await hostedSetupPromise;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const projectIsAuthorizedLocally = async () => {
|
|
573
|
+
const listed = await executeCli(['project', 'list'], {
|
|
574
|
+
timeoutMs: 30_000,
|
|
575
|
+
}).catch(() => ({ stdout: '[]', stderr: '' }));
|
|
576
|
+
return extractJsonArray(listed.stdout).some(
|
|
577
|
+
(item) => isRecord(item) && item.projectId === projectId,
|
|
578
|
+
);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const acquireProjectAuthorization = async ({ forceRefresh = false } = {}) => {
|
|
582
|
+
if (typeof authorizeProject !== 'function') {
|
|
583
|
+
throw toolError(
|
|
584
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
585
|
+
'This Framer project needs to be connected before Dexter can use it.',
|
|
586
|
+
409,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
let authorization = await authorizeProject({
|
|
590
|
+
projectId,
|
|
591
|
+
initiate: true,
|
|
592
|
+
forceRefresh,
|
|
593
|
+
signal: abortController.signal,
|
|
594
|
+
});
|
|
595
|
+
if (authorization?.framerProjectId !== projectId) {
|
|
596
|
+
throw toolError(
|
|
597
|
+
'FRAMER_AGENT_PROJECT_MISMATCH',
|
|
598
|
+
'Dexter received authorization for a different Framer project.',
|
|
599
|
+
409,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
if (authorization?.status === 'authorization_required') {
|
|
603
|
+
const url = authorizationUrl(authorization.authUrl);
|
|
604
|
+
reportActivity({
|
|
605
|
+
kind: 'status_update',
|
|
606
|
+
source: 'framer-agent',
|
|
607
|
+
message:
|
|
608
|
+
'Approve access to this Framer project in the browser. Dexter will continue automatically.',
|
|
609
|
+
});
|
|
610
|
+
try {
|
|
611
|
+
await openAuthorizationUrl(url);
|
|
612
|
+
} catch {
|
|
613
|
+
throw toolError(
|
|
614
|
+
'FRAMER_AGENT_AUTHORIZATION_OPEN_FAILED',
|
|
615
|
+
'Dexter could not open Framer authorization in the browser. Reconnect this project from Dexter settings.',
|
|
616
|
+
409,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const configuredExpiry = new Date(
|
|
620
|
+
String(authorization.expiresAt || ''),
|
|
621
|
+
).getTime();
|
|
622
|
+
const deadline = Math.min(
|
|
623
|
+
Number.isFinite(configuredExpiry)
|
|
624
|
+
? configuredExpiry
|
|
625
|
+
: Date.now() + authorizationTimeoutMs,
|
|
626
|
+
Date.now() + authorizationTimeoutMs,
|
|
627
|
+
);
|
|
628
|
+
while (
|
|
629
|
+
authorization?.status === 'authorization_required'
|
|
630
|
+
&& Date.now() < deadline
|
|
631
|
+
) {
|
|
632
|
+
await wait(
|
|
633
|
+
Math.max(
|
|
634
|
+
500,
|
|
635
|
+
Math.min(
|
|
636
|
+
5_000,
|
|
637
|
+
Number(authorization.pollIntervalMs) || AUTHORIZATION_POLL_MS,
|
|
638
|
+
),
|
|
639
|
+
),
|
|
640
|
+
abortController.signal,
|
|
641
|
+
);
|
|
642
|
+
authorization = await authorizeProject({
|
|
643
|
+
projectId,
|
|
644
|
+
initiate: false,
|
|
645
|
+
forceRefresh: false,
|
|
646
|
+
signal: abortController.signal,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const apiKey = String(authorization?.apiKey || '').trim();
|
|
651
|
+
if (authorization?.status !== 'ready' || !apiKey) {
|
|
652
|
+
throw toolError(
|
|
653
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
654
|
+
'Framer project authorization was not completed. Reconnect this project in Dexter and try again.',
|
|
655
|
+
409,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
await executeCli(['project', 'auth', projectId, apiKey], {
|
|
659
|
+
timeoutMs: 30_000,
|
|
660
|
+
});
|
|
661
|
+
reportActivity({
|
|
662
|
+
kind: 'status_update',
|
|
663
|
+
source: 'framer-agent',
|
|
664
|
+
message: 'This Framer project is authorized for the local harness.',
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const createSession = async () => {
|
|
669
|
+
return executeCli(['session', 'new', projectId], {
|
|
670
|
+
timeoutMs: SESSION_TIMEOUT_MS,
|
|
671
|
+
});
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const ensureSession = async () => {
|
|
675
|
+
await ensureHostedSetup();
|
|
676
|
+
if (sessionId) return sessionId;
|
|
677
|
+
const cached = sessionsByProject.get(projectId);
|
|
678
|
+
if (cached) {
|
|
679
|
+
sessionId = cached;
|
|
680
|
+
return sessionId;
|
|
681
|
+
}
|
|
682
|
+
if (sessionPromise) return sessionPromise;
|
|
683
|
+
sessionPromise = (async () => {
|
|
684
|
+
reportActivity({
|
|
685
|
+
kind: 'status_update',
|
|
686
|
+
source: 'framer-agent',
|
|
687
|
+
message: 'Connecting the coding harness to this Framer project.',
|
|
688
|
+
});
|
|
689
|
+
if (!(await projectIsAuthorizedLocally())) {
|
|
690
|
+
await acquireProjectAuthorization();
|
|
691
|
+
}
|
|
692
|
+
let created;
|
|
693
|
+
try {
|
|
694
|
+
created = await createSession();
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (isAuthorizationFailure(error)) {
|
|
697
|
+
await acquireProjectAuthorization({ forceRefresh: true });
|
|
698
|
+
try {
|
|
699
|
+
created = await createSession();
|
|
700
|
+
} catch (retryError) {
|
|
701
|
+
throw projectConnectionError(retryError);
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
throw projectConnectionError(error);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
sessionId = sessionIdFromOutput(created.stdout);
|
|
708
|
+
if (!sessionId) {
|
|
709
|
+
const refreshed = await executeCli(['session', 'list'], {
|
|
710
|
+
timeoutMs: 10_000,
|
|
711
|
+
}).catch(() => ({ stdout: '[]', stderr: '' }));
|
|
712
|
+
const connected = extractJsonArray(refreshed.stdout).find(
|
|
713
|
+
(item) => isRecord(item) && item.projectId === projectId && item.id,
|
|
714
|
+
);
|
|
715
|
+
sessionId = connected?.id ? String(connected.id) : null;
|
|
716
|
+
}
|
|
717
|
+
if (!sessionId) {
|
|
718
|
+
throw toolError(
|
|
719
|
+
'FRAMER_AGENT_SESSION_UNAVAILABLE',
|
|
720
|
+
'Framer Agent connected but did not return a usable project session.',
|
|
721
|
+
503,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
sessionsByProject.set(projectId, sessionId);
|
|
725
|
+
reportActivity({
|
|
726
|
+
kind: 'status_update',
|
|
727
|
+
source: 'framer-agent',
|
|
728
|
+
message: 'The coding harness is connected to the Framer canvas.',
|
|
729
|
+
});
|
|
730
|
+
return sessionId;
|
|
731
|
+
})().finally(() => {
|
|
732
|
+
sessionPromise = null;
|
|
733
|
+
});
|
|
734
|
+
return sessionPromise;
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const execCode = async (code, { timeoutMs } = {}) => {
|
|
738
|
+
let activeSessionId = await ensureSession();
|
|
739
|
+
try {
|
|
740
|
+
return await executeCli(['exec', '-s', activeSessionId], {
|
|
741
|
+
input: code,
|
|
742
|
+
timeoutMs,
|
|
743
|
+
});
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (
|
|
746
|
+
error?.code !== 'FRAMER_AGENT_COMMAND_FAILED'
|
|
747
|
+
|| !/session|connection|not found|closed|expired/i.test(
|
|
748
|
+
String(error?.message || ''),
|
|
749
|
+
)
|
|
750
|
+
) {
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
sessionsByProject.delete(projectId);
|
|
754
|
+
sessionId = null;
|
|
755
|
+
activeSessionId = await ensureSession();
|
|
756
|
+
return executeCli(['exec', '-s', activeSessionId], {
|
|
757
|
+
input: code,
|
|
758
|
+
timeoutMs,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const validateAuthoritativeNodeIds = async () => {
|
|
764
|
+
await ensureSession();
|
|
765
|
+
if (authoritativeNodeIds.length === 0) return;
|
|
766
|
+
if (nodeValidationPromise) return nodeValidationPromise;
|
|
767
|
+
nodeValidationPromise = (async () => {
|
|
768
|
+
reportActivity({
|
|
769
|
+
kind: 'status_update',
|
|
770
|
+
source: 'framer-agent',
|
|
771
|
+
message: 'Validating the selected Framer layers.',
|
|
772
|
+
});
|
|
773
|
+
const result = await execCode(
|
|
774
|
+
[
|
|
775
|
+
`const requestedIds = ${JSON.stringify(authoritativeNodeIds)};`,
|
|
776
|
+
'const resolved = await Promise.all(requestedIds.map(async (requestedId) => {',
|
|
777
|
+
' const node = await framer.agent.getNode({ id: requestedId },',
|
|
778
|
+
` { pagePath: ${JSON.stringify(activePagePath)} }).catch(() => null);`,
|
|
779
|
+
' return { requestedId, resolvedId: node?.id ?? null };',
|
|
780
|
+
'}));',
|
|
781
|
+
'console.log(JSON.stringify({ resolved }));',
|
|
782
|
+
].join('\n'),
|
|
783
|
+
{ timeoutMs: 30_000 },
|
|
784
|
+
);
|
|
785
|
+
const parsed = parseStructuredOutput(result.stdout);
|
|
786
|
+
const resolved = Array.isArray(parsed?.resolved)
|
|
787
|
+
? parsed.resolved
|
|
788
|
+
: [];
|
|
789
|
+
const invalid = authoritativeNodeIds.filter((requestedId) => {
|
|
790
|
+
const match = resolved.find(
|
|
791
|
+
(entry) =>
|
|
792
|
+
isRecord(entry)
|
|
793
|
+
&& entry.requestedId === requestedId,
|
|
794
|
+
);
|
|
795
|
+
return !match || match.resolvedId !== requestedId;
|
|
796
|
+
});
|
|
797
|
+
if (invalid.length > 0) {
|
|
798
|
+
throw toolError(
|
|
799
|
+
'FRAMER_AGENT_NODE_ID_INVALID',
|
|
800
|
+
`Dexter received Framer targets that do not resolve to exact native node IDs: ${invalid.join(', ')}. Re-select the affected layers in Framer and try again.`,
|
|
801
|
+
409,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
reportActivity({
|
|
805
|
+
kind: 'status_update',
|
|
806
|
+
source: 'framer-agent',
|
|
807
|
+
message: 'The selected Framer layers are ready.',
|
|
808
|
+
});
|
|
809
|
+
})().catch((error) => {
|
|
810
|
+
nodeValidationPromise = null;
|
|
811
|
+
throw error;
|
|
812
|
+
});
|
|
813
|
+
return nodeValidationPromise;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const normalizedResult = (result) => {
|
|
817
|
+
const parsed = parseStructuredOutput(result.stdout);
|
|
818
|
+
return {
|
|
819
|
+
...(parsed === null ? { output: boundedText(result.stdout, 200_000) } : { result: parsed }),
|
|
820
|
+
...(result.stderr.trim()
|
|
821
|
+
? { diagnostics: boundedText(result.stderr, 20_000) }
|
|
822
|
+
: {}),
|
|
823
|
+
...(branchChange ? { branch: branchChange } : {}),
|
|
824
|
+
};
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const markInspection = () => {
|
|
828
|
+
operationSequence += 1;
|
|
829
|
+
lastInspectionSequence = operationSequence;
|
|
830
|
+
lastInspectionAt = new Date().toISOString();
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const markMutation = () => {
|
|
834
|
+
operationSequence += 1;
|
|
835
|
+
lastMutationSequence = operationSequence;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
const synchronized = () =>
|
|
839
|
+
mutationCalls === 0 || lastInspectionSequence > lastMutationSequence;
|
|
840
|
+
|
|
841
|
+
async function invokeUnsafe(name, rawArguments = {}) {
|
|
842
|
+
if (closed) {
|
|
843
|
+
throw toolError(
|
|
844
|
+
'FRAMER_AGENT_RUNTIME_CLOSED',
|
|
845
|
+
'The Framer Agent runtime is closed.',
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (!allowedToolNames.has(name) || !TOOL_BY_NAME.has(name)) {
|
|
849
|
+
throw toolError(
|
|
850
|
+
'FRAMER_AGENT_TOOL_UNKNOWN',
|
|
851
|
+
`Unavailable Framer Agent tool: ${name}`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (!isRecord(rawArguments)) {
|
|
855
|
+
throw toolError(
|
|
856
|
+
'FRAMER_AGENT_TOOL_INPUT_INVALID',
|
|
857
|
+
'Framer Agent tool arguments must be an object.',
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
if (name === 'progress_update') {
|
|
861
|
+
const message = String(rawArguments.message || '').trim().slice(0, 500);
|
|
862
|
+
if (!message) {
|
|
863
|
+
throw toolError(
|
|
864
|
+
'FRAMER_AGENT_PROGRESS_INVALID',
|
|
865
|
+
'A progress message is required.',
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
reportActivity({
|
|
869
|
+
kind: 'status_update',
|
|
870
|
+
source: 'harness',
|
|
871
|
+
message,
|
|
872
|
+
});
|
|
873
|
+
return { acknowledged: true };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const startedAt = Date.now();
|
|
877
|
+
reportActivity({
|
|
878
|
+
kind: 'tool_active',
|
|
879
|
+
source: 'framer-agent',
|
|
880
|
+
name,
|
|
881
|
+
phase: 'started',
|
|
882
|
+
message:
|
|
883
|
+
name === 'framer_apply_changes' || name === 'framer_write'
|
|
884
|
+
? 'Updating the Framer project'
|
|
885
|
+
: 'Inspecting the Framer project',
|
|
886
|
+
});
|
|
887
|
+
try {
|
|
888
|
+
let result;
|
|
889
|
+
if (name === 'framer_instructions') {
|
|
890
|
+
readCalls += 1;
|
|
891
|
+
result = await execCode(
|
|
892
|
+
'console.log(await framer.agent.getSystemPrompt())',
|
|
893
|
+
);
|
|
894
|
+
} else if (name === 'framer_context') {
|
|
895
|
+
readCalls += 1;
|
|
896
|
+
result = await execCode(
|
|
897
|
+
[
|
|
898
|
+
'const [context, branch] = await Promise.all([',
|
|
899
|
+
' framer.agent.getContext(),',
|
|
900
|
+
' framer.agent.getActiveBranch().catch(() => null),',
|
|
901
|
+
']);',
|
|
902
|
+
'console.log(JSON.stringify({ context, branch }, null, 2));',
|
|
903
|
+
].join('\n'),
|
|
904
|
+
);
|
|
905
|
+
markInspection();
|
|
906
|
+
} else if (name === 'framer_read_project') {
|
|
907
|
+
readCalls += 1;
|
|
908
|
+
const queries = Array.isArray(rawArguments.queries)
|
|
909
|
+
? rawArguments.queries.slice(0, 50)
|
|
910
|
+
: [];
|
|
911
|
+
if (!queries.length || queries.some((query) => !isRecord(query))) {
|
|
912
|
+
throw toolError(
|
|
913
|
+
'FRAMER_AGENT_QUERY_INVALID',
|
|
914
|
+
'framer_read_project requires one or more query objects.',
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
918
|
+
result = await execCode(
|
|
919
|
+
[
|
|
920
|
+
`const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
921
|
+
'console.log(JSON.stringify(result, null, 2));',
|
|
922
|
+
].join('\n'),
|
|
923
|
+
);
|
|
924
|
+
markInspection();
|
|
925
|
+
} else if (name === 'framer_apply_changes') {
|
|
926
|
+
const changes = String(rawArguments.changes || '').trim();
|
|
927
|
+
if (!changes) {
|
|
928
|
+
throw toolError(
|
|
929
|
+
'FRAMER_AGENT_CHANGES_REQUIRED',
|
|
930
|
+
'Framer Agent changes are required.',
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
934
|
+
result = await execCode(
|
|
935
|
+
[
|
|
936
|
+
`const result = await framer.agent.applyChanges(${JSON.stringify(changes)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
937
|
+
'console.log(JSON.stringify(result ?? { ok: true }, null, 2));',
|
|
938
|
+
].join('\n'),
|
|
939
|
+
);
|
|
940
|
+
mutationCalls += 1;
|
|
941
|
+
markMutation();
|
|
942
|
+
} else if (name === 'framer_read') {
|
|
943
|
+
readCalls += 1;
|
|
944
|
+
result = await execCode(
|
|
945
|
+
validateCode(rawArguments.code, {
|
|
946
|
+
write: false,
|
|
947
|
+
allowPublishing,
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
markInspection();
|
|
951
|
+
} else if (name === 'framer_write') {
|
|
952
|
+
result = await execCode(
|
|
953
|
+
validateCode(rawArguments.code, {
|
|
954
|
+
write: true,
|
|
955
|
+
allowPublishing,
|
|
956
|
+
}),
|
|
957
|
+
);
|
|
958
|
+
mutationCalls += 1;
|
|
959
|
+
markMutation();
|
|
960
|
+
} else {
|
|
961
|
+
throw toolError(
|
|
962
|
+
'FRAMER_AGENT_TOOL_UNKNOWN',
|
|
963
|
+
`Unknown Framer Agent tool: ${name}`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
reportActivity({
|
|
967
|
+
kind: 'tool_active',
|
|
968
|
+
source: 'framer-agent',
|
|
969
|
+
name,
|
|
970
|
+
phase: 'completed',
|
|
971
|
+
message:
|
|
972
|
+
name === 'framer_apply_changes' || name === 'framer_write'
|
|
973
|
+
? 'Updated the Framer project'
|
|
974
|
+
: 'Finished inspecting the Framer project',
|
|
975
|
+
durationMs: Date.now() - startedAt,
|
|
976
|
+
});
|
|
977
|
+
return normalizedResult(result);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
trace?.warn?.('framer_agent_tool_failed', {
|
|
980
|
+
name,
|
|
981
|
+
code: error?.code,
|
|
982
|
+
message: error?.message,
|
|
983
|
+
});
|
|
984
|
+
reportActivity({
|
|
985
|
+
kind: 'tool_active',
|
|
986
|
+
source: 'framer-agent',
|
|
987
|
+
name,
|
|
988
|
+
phase: 'failed',
|
|
989
|
+
message:
|
|
990
|
+
name === 'framer_apply_changes' || name === 'framer_write'
|
|
991
|
+
? 'The Framer project update failed'
|
|
992
|
+
: 'The Framer project inspection failed',
|
|
993
|
+
durationMs: Date.now() - startedAt,
|
|
994
|
+
});
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function invoke(name, args = {}) {
|
|
1000
|
+
const operation = serial.then(() => invokeUnsafe(name, args));
|
|
1001
|
+
serial = operation.catch(() => undefined);
|
|
1002
|
+
return operation;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function startGateway() {
|
|
1006
|
+
if (gateway) return gateway;
|
|
1007
|
+
const { createServer } = await import('node:http');
|
|
1008
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
1009
|
+
server = createServer((request, response) => {
|
|
1010
|
+
const fail = (status, code, message) => {
|
|
1011
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
1012
|
+
response.end(JSON.stringify({ ok: false, error: { code, message } }));
|
|
1013
|
+
};
|
|
1014
|
+
if (request.method !== 'POST' || request.url !== '/tool') {
|
|
1015
|
+
fail(404, 'FRAMER_AGENT_GATEWAY_NOT_FOUND', 'Framer Agent tool endpoint not found.');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
1019
|
+
fail(401, 'FRAMER_AGENT_GATEWAY_UNAUTHORIZED', 'Invalid Framer Agent tool token.');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
let body = '';
|
|
1023
|
+
request.on('data', (chunk) => {
|
|
1024
|
+
body += chunk.toString('utf8');
|
|
1025
|
+
if (body.length > 512 * 1024) request.destroy();
|
|
1026
|
+
});
|
|
1027
|
+
request.on('end', async () => {
|
|
1028
|
+
try {
|
|
1029
|
+
const payload = JSON.parse(body || '{}');
|
|
1030
|
+
const result = await invoke(
|
|
1031
|
+
String(payload.name || ''),
|
|
1032
|
+
payload.arguments || {},
|
|
1033
|
+
);
|
|
1034
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1035
|
+
response.end(JSON.stringify({ ok: true, result }));
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
fail(
|
|
1038
|
+
Number(error?.statusCode) || 400,
|
|
1039
|
+
error?.code || 'FRAMER_AGENT_TOOL_FAILED',
|
|
1040
|
+
error?.message || 'Framer Agent tool failed.',
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
await new Promise((resolve, reject) => {
|
|
1046
|
+
server.once('error', reject);
|
|
1047
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
1048
|
+
});
|
|
1049
|
+
const address = server.address();
|
|
1050
|
+
if (!address || typeof address !== 'object') {
|
|
1051
|
+
throw new Error('Framer Agent tool gateway did not bind to a local port.');
|
|
1052
|
+
}
|
|
1053
|
+
gateway = {
|
|
1054
|
+
url: `http://127.0.0.1:${address.port}/tool`,
|
|
1055
|
+
token,
|
|
1056
|
+
};
|
|
1057
|
+
return gateway;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return {
|
|
1061
|
+
definitions,
|
|
1062
|
+
toolNames: definitions.map((definition) => definition.name),
|
|
1063
|
+
preflight: validateAuthoritativeNodeIds,
|
|
1064
|
+
invoke,
|
|
1065
|
+
startGateway,
|
|
1066
|
+
synchronize: async () => ({
|
|
1067
|
+
synchronized: synchronized(),
|
|
1068
|
+
sourceHash: null,
|
|
1069
|
+
changed: mutationCalls > 0,
|
|
1070
|
+
}),
|
|
1071
|
+
inspect: async () => ({
|
|
1072
|
+
synchronized: synchronized(),
|
|
1073
|
+
sourceHash: null,
|
|
1074
|
+
changed: mutationCalls > 0,
|
|
1075
|
+
mutationCalls,
|
|
1076
|
+
readCalls,
|
|
1077
|
+
branch: branchChange,
|
|
1078
|
+
lastInspectionAt,
|
|
1079
|
+
}),
|
|
1080
|
+
sourceHash: () => null,
|
|
1081
|
+
hasPendingChanges: () => false,
|
|
1082
|
+
directWorkspace: true,
|
|
1083
|
+
summary: () => ({
|
|
1084
|
+
projectId,
|
|
1085
|
+
mutationCalls,
|
|
1086
|
+
readCalls,
|
|
1087
|
+
branch: branchChange,
|
|
1088
|
+
lastInspectionAt,
|
|
1089
|
+
}),
|
|
1090
|
+
fatalInfrastructureError: () => null,
|
|
1091
|
+
waitForFatalInfrastructure: () => new Promise(() => undefined),
|
|
1092
|
+
close: async ({ waitForPending = true } = {}) => {
|
|
1093
|
+
closed = true;
|
|
1094
|
+
abortController.abort();
|
|
1095
|
+
if (waitForPending) await serial.catch(() => undefined);
|
|
1096
|
+
if (server) {
|
|
1097
|
+
if (!waitForPending) server.closeAllConnections?.();
|
|
1098
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1099
|
+
server = null;
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function __resetFramerAgentToolRuntimeForTests() {
|
|
1106
|
+
sessionsByProject.clear();
|
|
1107
|
+
hostedSetupPromise = null;
|
|
1108
|
+
}
|