@gakim-digital/dexter-bridge 0.11.0 → 0.11.1

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.
@@ -1,20 +1,62 @@
1
1
  import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
2
3
  import { spawn } from 'node:child_process';
3
4
  import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import {
7
+ BEHAVIOR_ACTIVATIONS,
8
+ BEHAVIOR_LAYOUT_EFFECTS,
9
+ BEHAVIOR_MECHANISMS,
10
+ BEHAVIOR_PRESENTATIONS,
11
+ canonicalIdMappings,
12
+ evidenceNodeIds,
13
+ mergeBehaviorPlans,
14
+ normalizeBehaviorEvidence,
15
+ normalizeBehaviorPlan,
16
+ summarizeFramerChanges,
17
+ verifyBehaviorEvidence,
18
+ } from './framerBehaviorContract.js';
4
19
 
5
20
  const require = createRequire(import.meta.url);
6
21
  const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
22
+ const MAX_MODEL_RESULT_BYTES = 120 * 1024;
7
23
  const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60_000;
8
24
  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}$/;
25
+ const MAX_UNSAFE_WRITE_FAILURES = 2;
26
+ const MAX_LOW_LEVEL_WRITE_FAILURES = 3;
27
+ const MAX_GUIDANCE_BYTES = 180 * 1024;
28
+ const MAX_GUIDANCE_FILE_BYTES = 48 * 1024;
29
+ const PROJECT_ID_PATTERN = /^[A-Za-z0-9]{20}$/;
30
+ const PROJECT_SEGMENT_PATTERN =
31
+ /^(?:.+--)?([A-Za-z0-9]+)(?:-[A-Za-z0-9]+)?$/;
32
+ const FRAMER_PROJECT_HOST_PATTERN = /(^|\.)framer(?:local)?\.com$/i;
12
33
  const PAGE_PATH_PATTERN = /^\/[^\0\r\n]{0,499}$/;
13
34
  const UNSAFE_EXEC_PATTERN =
14
35
  /\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
15
36
  const MUTATING_EXEC_PATTERN =
16
37
  /\.(?: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
-
38
+ const TASK_DOMAINS = [
39
+ 'visual',
40
+ 'content',
41
+ 'links',
42
+ 'interactions',
43
+ 'responsive',
44
+ 'data',
45
+ 'code',
46
+ 'structure',
47
+ ];
48
+ const VERIFICATION_CHECKS = [
49
+ 'structural',
50
+ 'visual',
51
+ 'links',
52
+ 'interactions',
53
+ 'responsive',
54
+ 'code',
55
+ 'data',
56
+ ];
57
+ const GENERIC_VERIFICATION_CHECKS = VERIFICATION_CHECKS.filter(
58
+ (check) => check !== 'interactions',
59
+ );
18
60
  const EMPTY_SCHEMA = {
19
61
  type: 'object',
20
62
  properties: {},
@@ -25,7 +67,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
25
67
  {
26
68
  name: 'progress_update',
27
69
  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.',
70
+ 'Tell the user what you are about to do or are doing now in one or two natural first-person sentences. Name the specific page area, component, or behavior and the concrete change; optionally say what you will check next. Avoid generic updates such as "working on the next step", "thinking", "loading", or "making changes". Call this before the first Framer inspection or edit and at meaningful phase changes.',
29
71
  inputSchema: {
30
72
  type: 'object',
31
73
  properties: {
@@ -38,8 +80,18 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
38
80
  {
39
81
  name: 'framer_instructions',
40
82
  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,
83
+ 'Get concise Framer Agent guidance, or request targeted official API documentation when an exact method or type is unclear. Most runs should rely on the tool descriptions and call this only when needed.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ queries: {
88
+ type: 'array',
89
+ maxItems: 8,
90
+ items: { type: 'string', minLength: 1, maxLength: 120 },
91
+ },
92
+ },
93
+ additionalProperties: false,
94
+ },
43
95
  },
44
96
  {
45
97
  name: 'framer_context',
@@ -61,11 +113,275 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
61
113
  items: { type: 'object', additionalProperties: true },
62
114
  },
63
115
  pagePath: { type: 'string', maxLength: 500, default: '/' },
116
+ verifies: {
117
+ type: 'array',
118
+ uniqueItems: true,
119
+ maxItems: 6,
120
+ items: { type: 'string', enum: GENERIC_VERIFICATION_CHECKS },
121
+ description:
122
+ 'Verification evidence this read is intended to establish after mutations. Interaction verification requires framer_verify_interactions.',
123
+ },
64
124
  },
65
125
  required: ['queries'],
66
126
  additionalProperties: false,
67
127
  },
68
128
  },
129
+ {
130
+ name: 'framer_plan_task',
131
+ description:
132
+ 'After inspecting the live Framer target and before the first mutation, declare your semantic understanding, observable behavior requirements, chosen implementation mechanisms, and completion evidence. Dexter does not classify the request or choose mechanisms for you.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ summary: {
137
+ type: 'string',
138
+ minLength: 10,
139
+ maxLength: 1000,
140
+ },
141
+ domains: {
142
+ type: 'array',
143
+ minItems: 1,
144
+ uniqueItems: true,
145
+ items: { type: 'string', enum: TASK_DOMAINS },
146
+ },
147
+ verification: {
148
+ type: 'array',
149
+ minItems: 1,
150
+ uniqueItems: true,
151
+ items: { type: 'string', enum: VERIFICATION_CHECKS },
152
+ },
153
+ behaviors: {
154
+ type: 'array',
155
+ maxItems: 20,
156
+ description:
157
+ 'Required for interaction work. Describe observable behavior independently from its implementation.',
158
+ items: {
159
+ type: 'object',
160
+ properties: {
161
+ id: { type: 'string', minLength: 1, maxLength: 120 },
162
+ description: { type: 'string', minLength: 5, maxLength: 500 },
163
+ triggerDescription: {
164
+ type: 'string',
165
+ minLength: 3,
166
+ maxLength: 300,
167
+ },
168
+ activation: {
169
+ type: 'string',
170
+ enum: BEHAVIOR_ACTIVATIONS,
171
+ },
172
+ presentation: {
173
+ type: 'string',
174
+ enum: BEHAVIOR_PRESENTATIONS,
175
+ },
176
+ stateChange: { type: 'boolean' },
177
+ layoutEffect: {
178
+ type: 'string',
179
+ enum: BEHAVIOR_LAYOUT_EFFECTS,
180
+ },
181
+ reversible: { type: 'boolean' },
182
+ repeatable: { type: 'boolean' },
183
+ expectedTargets: {
184
+ type: 'integer',
185
+ minimum: 1,
186
+ maximum: 50,
187
+ },
188
+ expectedContent: {
189
+ type: 'array',
190
+ maxItems: 20,
191
+ items: { type: 'string', minLength: 1, maxLength: 500 },
192
+ },
193
+ initialState: {
194
+ type: 'string',
195
+ minLength: 1,
196
+ maxLength: 300,
197
+ },
198
+ activatedState: {
199
+ type: 'string',
200
+ minLength: 1,
201
+ maxLength: 300,
202
+ },
203
+ },
204
+ required: [
205
+ 'id',
206
+ 'description',
207
+ 'triggerDescription',
208
+ 'activation',
209
+ 'presentation',
210
+ 'stateChange',
211
+ 'layoutEffect',
212
+ 'reversible',
213
+ 'repeatable',
214
+ 'expectedTargets',
215
+ 'initialState',
216
+ 'activatedState',
217
+ ],
218
+ additionalProperties: false,
219
+ },
220
+ },
221
+ mechanisms: {
222
+ type: 'array',
223
+ maxItems: 20,
224
+ description:
225
+ 'Required for every declared behavior. Choose the mechanism from live project capabilities and explain why it satisfies the behavior.',
226
+ items: {
227
+ type: 'object',
228
+ properties: {
229
+ behaviorId: { type: 'string', minLength: 1, maxLength: 120 },
230
+ mechanism: {
231
+ type: 'string',
232
+ enum: BEHAVIOR_MECHANISMS,
233
+ },
234
+ rationale: {
235
+ type: 'string',
236
+ minLength: 10,
237
+ maxLength: 800,
238
+ },
239
+ representativeFirst: { type: 'boolean', default: true },
240
+ },
241
+ required: ['behaviorId', 'mechanism', 'rationale'],
242
+ additionalProperties: false,
243
+ },
244
+ },
245
+ rejectedMechanisms: {
246
+ type: 'array',
247
+ maxItems: 30,
248
+ description:
249
+ 'Mechanisms already disproven by user-observed failure or prior verification. Do not silently reuse them.',
250
+ items: {
251
+ type: 'object',
252
+ properties: {
253
+ behaviorId: { type: 'string', minLength: 1, maxLength: 120 },
254
+ behaviorSignature: {
255
+ type: 'string',
256
+ minLength: 1,
257
+ maxLength: 128,
258
+ },
259
+ mechanism: {
260
+ type: 'string',
261
+ enum: BEHAVIOR_MECHANISMS,
262
+ },
263
+ reason: { type: 'string', minLength: 5, maxLength: 800 },
264
+ source: {
265
+ type: 'string',
266
+ enum: ['user_reported_failure', 'verification_failure'],
267
+ },
268
+ },
269
+ required: ['mechanism', 'reason'],
270
+ additionalProperties: false,
271
+ },
272
+ },
273
+ },
274
+ required: ['summary', 'domains', 'verification'],
275
+ additionalProperties: false,
276
+ },
277
+ },
278
+ {
279
+ name: 'framer_verify_interactions',
280
+ description:
281
+ 'Verify the observable behavior contract from the live Framer tree. The runtime derives the required action and invariants from the planned behavior and mechanism; supply only canonical evidence node IDs. This is the only tool that can satisfy interaction verification.',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ pagePath: { type: 'string', maxLength: 500, default: '/' },
286
+ checks: {
287
+ type: 'array',
288
+ minItems: 1,
289
+ maxItems: 20,
290
+ items: {
291
+ type: 'object',
292
+ properties: {
293
+ description: {
294
+ type: 'string',
295
+ minLength: 1,
296
+ maxLength: 300,
297
+ },
298
+ behaviorId: {
299
+ type: 'string',
300
+ minLength: 1,
301
+ maxLength: 120,
302
+ },
303
+ triggerNodeId: {
304
+ type: 'string',
305
+ minLength: 1,
306
+ maxLength: 200,
307
+ },
308
+ event: {
309
+ type: 'string',
310
+ minLength: 1,
311
+ maxLength: 80,
312
+ },
313
+ targetNodeId: {
314
+ type: 'string',
315
+ minLength: 1,
316
+ maxLength: 200,
317
+ },
318
+ componentNodeId: {
319
+ type: 'string',
320
+ minLength: 1,
321
+ maxLength: 200,
322
+ },
323
+ instanceNodeIds: {
324
+ type: 'array',
325
+ maxItems: 50,
326
+ uniqueItems: true,
327
+ items: {
328
+ type: 'string',
329
+ minLength: 1,
330
+ maxLength: 200,
331
+ },
332
+ },
333
+ openVariantNodeId: {
334
+ type: 'string',
335
+ minLength: 1,
336
+ maxLength: 200,
337
+ },
338
+ closedVariantNodeId: {
339
+ type: 'string',
340
+ minLength: 1,
341
+ maxLength: 200,
342
+ },
343
+ openAnswerNodeId: {
344
+ type: 'string',
345
+ minLength: 1,
346
+ maxLength: 200,
347
+ },
348
+ closedAnswerNodeId: {
349
+ type: 'string',
350
+ minLength: 1,
351
+ maxLength: 200,
352
+ },
353
+ },
354
+ required: [
355
+ 'behaviorId',
356
+ 'triggerNodeId',
357
+ 'event',
358
+ ],
359
+ additionalProperties: false,
360
+ },
361
+ },
362
+ },
363
+ required: ['checks'],
364
+ additionalProperties: false,
365
+ },
366
+ },
367
+ {
368
+ name: 'framer_query_images',
369
+ description:
370
+ 'Search Framer Agent’s approved image source for real-world imagery. Use this only when the design needs a concrete photo or editorial image and no suitable user attachment or existing project image is available.',
371
+ inputSchema: {
372
+ type: 'object',
373
+ properties: {
374
+ query: { type: 'string', minLength: 2, maxLength: 200 },
375
+ count: { type: 'integer', minimum: 1, maximum: 8, default: 4 },
376
+ orientation: {
377
+ type: 'string',
378
+ enum: ['landscape', 'portrait', 'squarish'],
379
+ },
380
+ },
381
+ required: ['query'],
382
+ additionalProperties: false,
383
+ },
384
+ },
69
385
  {
70
386
  name: 'framer_apply_changes',
71
387
  description:
@@ -88,6 +404,14 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
88
404
  type: 'object',
89
405
  properties: {
90
406
  code: { type: 'string', minLength: 1, maxLength: 200_000 },
407
+ verifies: {
408
+ type: 'array',
409
+ uniqueItems: true,
410
+ maxItems: 6,
411
+ items: { type: 'string', enum: GENERIC_VERIFICATION_CHECKS },
412
+ description:
413
+ 'Verification evidence this read is intended to establish after mutations. Interaction verification requires framer_verify_interactions.',
414
+ },
91
415
  },
92
416
  required: ['code'],
93
417
  additionalProperties: false,
@@ -96,13 +420,20 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
96
420
  {
97
421
  name: 'framer_write',
98
422
  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.',
423
+ '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. If a write is rejected as unsafe, do not retry JavaScript variants; switch to framer_apply_changes or report the concrete blocker.',
100
424
  inputSchema: {
101
425
  type: 'object',
102
426
  properties: {
103
427
  code: { type: 'string', minLength: 1, maxLength: 200_000 },
428
+ reason: {
429
+ type: 'string',
430
+ minLength: 10,
431
+ maxLength: 500,
432
+ description:
433
+ 'Explain the specific Framer capability needed and why framer_apply_changes cannot perform it.',
434
+ },
104
435
  },
105
- required: ['code'],
436
+ required: ['code', 'reason'],
106
437
  additionalProperties: false,
107
438
  },
108
439
  },
@@ -131,6 +462,67 @@ function boundedText(value, maximum) {
131
462
  return `${text.slice(0, maximum)}\n...[output clipped]`;
132
463
  }
133
464
 
465
+ function hasInstalledFramerSkill(env) {
466
+ const home = String(env?.HOME || '').trim();
467
+ if (!home) return false;
468
+ return [
469
+ path.join(home, '.agents', 'skills', 'framer', 'SKILL.md'),
470
+ path.join(home, '.claude', 'skills', 'framer', 'SKILL.md'),
471
+ ].some((skillPath) => fs.existsSync(skillPath));
472
+ }
473
+
474
+ function framerSkillRoot(env) {
475
+ const home = String(env?.HOME || '').trim();
476
+ if (!home) return null;
477
+ return [
478
+ path.join(home, '.agents', 'skills', 'framer'),
479
+ path.join(home, '.claude', 'skills', 'framer'),
480
+ ].find((root) => fs.existsSync(path.join(root, 'SKILL.md'))) || null;
481
+ }
482
+
483
+ function installedFramerAgentVersion() {
484
+ try {
485
+ let current = path.dirname(resolveFramerAgentCli());
486
+ for (let depth = 0; depth < 8; depth += 1) {
487
+ const packagePath = path.join(current, 'package.json');
488
+ if (fs.existsSync(packagePath)) {
489
+ const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
490
+ if (manifest?.name === '@framer/agent') {
491
+ return String(manifest.version || '').trim() || null;
492
+ }
493
+ }
494
+ const parent = path.dirname(current);
495
+ if (parent === current) break;
496
+ current = parent;
497
+ }
498
+ } catch {
499
+ // Version telemetry must never block a run.
500
+ }
501
+ return null;
502
+ }
503
+
504
+ function readGuidanceFile(projectRoot, relativePath, remainingBytes) {
505
+ const normalized = relativePath.replaceAll('\\', '/');
506
+ if (
507
+ normalized.startsWith('/')
508
+ || normalized.split('/').some((segment) => segment === '..')
509
+ ) return null;
510
+ const absolutePath = path.resolve(projectRoot, normalized);
511
+ if (
512
+ absolutePath !== projectRoot
513
+ && !absolutePath.startsWith(`${projectRoot}${path.sep}`)
514
+ ) return null;
515
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
516
+ return null;
517
+ }
518
+ const maximum = Math.min(MAX_GUIDANCE_FILE_BYTES, remainingBytes);
519
+ if (maximum <= 0) return null;
520
+ return {
521
+ path: normalized,
522
+ content: boundedText(fs.readFileSync(absolutePath, 'utf8'), maximum),
523
+ };
524
+ }
525
+
134
526
  function normalizeProjectId(value) {
135
527
  const projectId = String(value || '').trim();
136
528
  if (!PROJECT_ID_PATTERN.test(projectId)) {
@@ -142,6 +534,55 @@ function normalizeProjectId(value) {
142
534
  return projectId;
143
535
  }
144
536
 
537
+ function projectIdFromUrl(url) {
538
+ const segments = url.pathname.split('/').filter(Boolean);
539
+ if (
540
+ segments.length !== 2
541
+ || segments[0]?.toLowerCase() !== 'projects'
542
+ || !segments[1]
543
+ ) return '';
544
+ try {
545
+ const projectId = decodeURIComponent(segments[1])
546
+ .match(PROJECT_SEGMENT_PATTERN)?.[1] || '';
547
+ return PROJECT_ID_PATTERN.test(projectId) ? projectId : '';
548
+ } catch {
549
+ return '';
550
+ }
551
+ }
552
+
553
+ function normalizeProjectUrl(value, expectedProjectId) {
554
+ let url;
555
+ try {
556
+ url = new URL(String(value || '').trim());
557
+ } catch {
558
+ throw toolError(
559
+ 'FRAMER_AGENT_PROJECT_URL_INVALID',
560
+ 'The exact Framer project URL is missing or invalid.',
561
+ );
562
+ }
563
+ if (
564
+ url.protocol !== 'https:'
565
+ || !FRAMER_PROJECT_HOST_PATTERN.test(url.hostname)
566
+ || url.username
567
+ || url.password
568
+ ) {
569
+ throw toolError(
570
+ 'FRAMER_AGENT_PROJECT_URL_INVALID',
571
+ 'The exact Framer project URL is missing or invalid.',
572
+ );
573
+ }
574
+ const projectId = projectIdFromUrl(url);
575
+ if (!projectId || projectId !== expectedProjectId) {
576
+ throw toolError(
577
+ 'FRAMER_AGENT_PROJECT_MISMATCH',
578
+ 'The Framer project URL does not match the project selected in Dexter.',
579
+ 409,
580
+ );
581
+ }
582
+ url.hash = '';
583
+ return url.toString();
584
+ }
585
+
145
586
  function normalizePagePath(value) {
146
587
  const pagePath = String(value || '/').trim() || '/';
147
588
  if (!PAGE_PATH_PATTERN.test(pagePath)) {
@@ -164,6 +605,156 @@ function uniqueStrings(values, maximum = 500) {
164
605
  ).slice(0, maximum);
165
606
  }
166
607
 
608
+ function normalizeVerificationValues(values, allowed) {
609
+ return uniqueStrings(Array.isArray(values) ? values : [])
610
+ .filter((value) => allowed.includes(value));
611
+ }
612
+
613
+ function normalizeTaskPlan(rawArguments, inheritedRejectedMechanisms = []) {
614
+ const summary = String(rawArguments.summary || '').trim().slice(0, 1000);
615
+ const domains = normalizeVerificationValues(
616
+ rawArguments.domains,
617
+ TASK_DOMAINS,
618
+ );
619
+ const requestedVerification = normalizeVerificationValues(
620
+ rawArguments.verification,
621
+ VERIFICATION_CHECKS,
622
+ );
623
+ if (summary.length < 10 || domains.length === 0) {
624
+ throw toolError(
625
+ 'FRAMER_HARNESS_PLAN_INVALID',
626
+ 'Describe the understood task and declare at least one task domain.',
627
+ );
628
+ }
629
+ const verification = new Set(['structural', ...requestedVerification]);
630
+ if (
631
+ domains.some((domain) =>
632
+ ['visual', 'interactions', 'responsive'].includes(domain))
633
+ ) verification.add('visual');
634
+ if (domains.includes('links')) verification.add('links');
635
+ if (domains.includes('interactions')) verification.add('interactions');
636
+ if (domains.includes('responsive')) verification.add('responsive');
637
+ if (domains.includes('code')) verification.add('code');
638
+ if (domains.includes('data')) verification.add('data');
639
+ const behaviorPlan = normalizeBehaviorPlan(
640
+ rawArguments,
641
+ inheritedRejectedMechanisms,
642
+ );
643
+ if (
644
+ domains.includes('interactions')
645
+ && behaviorPlan.behaviors.length === 0
646
+ ) {
647
+ throw toolError(
648
+ 'FRAMER_BEHAVIOR_CONTRACT_REQUIRED',
649
+ 'Interaction work requires observable behaviors and one compatible mechanism decision per behavior.',
650
+ );
651
+ }
652
+ if (behaviorPlan.behaviors.length > 0) {
653
+ if (!domains.includes('interactions')) domains.push('interactions');
654
+ verification.add('interactions');
655
+ }
656
+ return {
657
+ summary,
658
+ domains,
659
+ verification: [...verification],
660
+ ...behaviorPlan,
661
+ };
662
+ }
663
+
664
+ function workingPreferencesFromAssignment(assignment) {
665
+ const context = isRecord(assignment?.context) ? assignment.context : {};
666
+ const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
667
+ const supplied = isRecord(context.workingPreferences)
668
+ ? context.workingPreferences
669
+ : {};
670
+ const selectedNodeIds = uniqueStrings([
671
+ ...(Array.isArray(context.selectedNodeIds)
672
+ ? context.selectedNodeIds
673
+ : []),
674
+ ...(Array.isArray(grant.selectedNodeIds)
675
+ ? grant.selectedNodeIds
676
+ : []),
677
+ ]);
678
+ const selectionFirst =
679
+ supplied.focus === 'selection-first'
680
+ || (
681
+ selectedNodeIds.length > 0
682
+ && grant.writeScope === 'selection'
683
+ );
684
+ return {
685
+ mode: selectionFirst ? 'selection-first' : 'standard',
686
+ writeScope: String(
687
+ supplied.writeScope || grant.writeScope || 'none',
688
+ ).trim() || 'none',
689
+ selectionFirst,
690
+ preferBatchedMutations: supplied.preferBatchedMutations !== false,
691
+ avoidRepeatedInspection: supplied.avoidRepeatedInspection !== false,
692
+ expandScopeOnlyWhenNeeded:
693
+ supplied.expandScopeOnlyWhenNeeded !== false,
694
+ };
695
+ }
696
+
697
+ function toolDefinitionsForPreferences(requestedTools, preferences) {
698
+ return FRAMER_AGENT_TOOL_DEFINITIONS
699
+ .filter((tool) => requestedTools.has(tool.name))
700
+ .map((tool) => {
701
+ if (!preferences.selectionFirst) return tool;
702
+ if (tool.name === 'framer_context') {
703
+ return {
704
+ ...tool,
705
+ description:
706
+ 'Read broad project context only when the selected subtree lacks a concrete dependency required for the request. Do not use this as the default first inspection.',
707
+ };
708
+ }
709
+ if (tool.name === 'framer_read_project') {
710
+ return {
711
+ ...tool,
712
+ description:
713
+ 'Run supported Framer readProject queries, especially screenshots. For node-tree inspection, prefer framer_read with framer.agent.getNode({ id }), getNodes({ ids }), serialize, or other focused node APIs. Batch related queries when practical.',
714
+ };
715
+ }
716
+ if (tool.name === 'framer_apply_changes') {
717
+ return {
718
+ ...tool,
719
+ description:
720
+ 'Apply related changes inside the authorized selection in coherent batches when practical. Read the returned diagnostics, verify the result, and make any further repair batches the work requires.',
721
+ };
722
+ }
723
+ if (tool.name === 'framer_read') {
724
+ return {
725
+ ...tool,
726
+ description:
727
+ 'Use focused JavaScript for node-tree inspection and Framer capabilities not covered by readProject. Start with the selected nodes, reuse prior evidence, and expand only when a concrete dependency requires it.',
728
+ };
729
+ }
730
+ if (tool.name === 'framer_write') {
731
+ return {
732
+ ...tool,
733
+ description:
734
+ 'Use project-scoped JavaScript only when framer_apply_changes cannot perform the required authorized capability. Explain that limitation in reason, prefer cohesive operations, and keep mutations within the authorized write scope. Do not retry JavaScript variants after a safety rejection.',
735
+ };
736
+ }
737
+ return tool;
738
+ });
739
+ }
740
+
741
+ function boundedStructuredValue(value, maximum = MAX_MODEL_RESULT_BYTES) {
742
+ let serialized;
743
+ try {
744
+ serialized = JSON.stringify(value);
745
+ } catch {
746
+ return {
747
+ truncated: true,
748
+ output: '[Framer result could not be serialized.]',
749
+ };
750
+ }
751
+ if (Buffer.byteLength(serialized, 'utf8') <= maximum) return value;
752
+ return {
753
+ truncated: true,
754
+ output: boundedText(serialized, maximum),
755
+ };
756
+ }
757
+
167
758
  function authoritativeNodeIdsFromAssignment(assignment) {
168
759
  const context = isRecord(assignment?.context) ? assignment.context : {};
169
760
  const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
@@ -237,89 +828,6 @@ function abortError() {
237
828
  });
238
829
  }
239
830
 
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
831
  function parseStructuredOutput(value) {
324
832
  const text = String(value || '').trim();
325
833
  if (!text) return null;
@@ -470,8 +978,7 @@ function sessionIdFromOutput(value) {
470
978
  function isAuthorizationFailure(error) {
471
979
  return (
472
980
  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(
981
+ || /authoriz|log in|login|api key|does not have access|session expired/i.test(
475
982
  String(error?.message || ''),
476
983
  )
477
984
  );
@@ -481,7 +988,14 @@ function projectConnectionError(error) {
481
988
  if (/project not found/i.test(String(error?.message || ''))) {
482
989
  return toolError(
483
990
  '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.',
991
+ 'The project key passed Server API verification, but Framer Agent could not find the authoritative project ID. Reopen the project in Framer, reconnect it in Dexter, and try again.',
992
+ 409,
993
+ );
994
+ }
995
+ if (isAuthorizationFailure(error)) {
996
+ return toolError(
997
+ 'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
998
+ 'The project key passed Server API verification, but Framer Agent rejected it while creating the editing session. Reconnect the open project in Dexter with a current key from General → API Keys, then try again.',
485
999
  409,
486
1000
  );
487
1001
  }
@@ -496,20 +1010,36 @@ export function createFramerAgentToolRuntime({
496
1010
  onActivity,
497
1011
  runCli = runFramerAgentCli,
498
1012
  authorizeProject,
499
- openAuthorizationUrl = openFramerAuthorizationUrl,
500
- authorizationTimeoutMs = AUTHORIZATION_TIMEOUT_MS,
1013
+ verifyProjectAuthorization,
501
1014
  } = {}) {
502
1015
  const projectId = normalizeProjectId(assignment?.context?.framerProjectId);
1016
+ const projectUrl = normalizeProjectUrl(
1017
+ assignment?.context?.framerProjectUrl,
1018
+ projectId,
1019
+ );
1020
+ const sessionCacheKey = projectId;
503
1021
  const requestedTools = Array.isArray(assignment?.toolProtocol?.tools)
504
1022
  ? assignment.toolProtocol.tools
505
1023
  : FRAMER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name);
506
1024
  const allowedToolNames = new Set(
507
1025
  requestedTools.filter((name) => TOOL_BY_NAME.has(name)),
508
1026
  );
509
- const definitions = FRAMER_AGENT_TOOL_DEFINITIONS.filter((tool) =>
510
- allowedToolNames.has(tool.name),
1027
+ const workingPreferences = workingPreferencesFromAssignment(assignment);
1028
+ const definitions = toolDefinitionsForPreferences(
1029
+ allowedToolNames,
1030
+ workingPreferences,
511
1031
  );
512
1032
  const allowPublishing = assignment?.context?.permissions?.publish === true;
1033
+ const inheritedRejectedMechanisms =
1034
+ assignment?.context?.repairContext?.rejectedMechanisms
1035
+ || assignment?.repairContext?.rejectedMechanisms
1036
+ || [];
1037
+ const authorizedInstanceContainerIds = uniqueStrings(
1038
+ assignment?.context?.permissions?.scopedComponentArtifacts
1039
+ ?.instanceContainerNodeIds
1040
+ || assignment?.context?.contextGrant?.writableNodeIds
1041
+ || [],
1042
+ );
513
1043
  const activePagePath = normalizePagePath(
514
1044
  assignment?.context?.activePagePath,
515
1045
  );
@@ -523,9 +1053,53 @@ export function createFramerAgentToolRuntime({
523
1053
  let closed = false;
524
1054
  let serial = Promise.resolve();
525
1055
  let mutationCalls = 0;
1056
+ let applyChangesCalls = 0;
1057
+ let lowLevelWriteCalls = 0;
1058
+ let imageSearchCalls = 0;
526
1059
  let readCalls = 0;
1060
+ let progressCalls = 0;
1061
+ let toolCallCount = 0;
1062
+ const toolCalls = [];
1063
+ let repeatedFailureFingerprint = null;
1064
+ let repeatedFailureCount = 0;
1065
+ let repeatedFailureStops = 0;
1066
+ let unsafeWriteFailures = 0;
1067
+ let lowLevelWriteFailures = 0;
1068
+ let lowLevelWriteDisabled = false;
1069
+ let lowLevelWriteBlockedCalls = 0;
1070
+ let instructionFallbacks = 0;
1071
+ let instructionLookupUnavailable = false;
527
1072
  let branchChange = null;
528
1073
  let lastInspectionAt = null;
1074
+ let lastMutationAt = null;
1075
+ let screenshotCalls = 0;
1076
+ let interactionVerificationCalls = 0;
1077
+ let interactionVerification = {
1078
+ completed: false,
1079
+ verificationLevel: 'unverified',
1080
+ checks: [],
1081
+ behaviors: [],
1082
+ verifiedAt: null,
1083
+ };
1084
+ const representativeVerifiedBehaviorIds = new Set();
1085
+ const repeatedBehaviorVerificationFailures = new Map();
1086
+ const mutationReceipts = [];
1087
+ let screenshotBeforeMutation = false;
1088
+ let lastScreenshotSequence = 0;
1089
+ let taskPlan = null;
1090
+ const verificationEvidence = new Map();
1091
+ let guidanceMetadata = {
1092
+ loaded: false,
1093
+ contentHash: null,
1094
+ projectContextHash: null,
1095
+ files: [],
1096
+ bytes: 0,
1097
+ };
1098
+ let promptMetadata = {
1099
+ doctrineLoaded: false,
1100
+ doctrineVersion: null,
1101
+ };
1102
+ const framerAgentVersion = installedFramerAgentVersion();
529
1103
  let operationSequence = 0;
530
1104
  let lastMutationSequence = 0;
531
1105
  let lastInspectionSequence = 0;
@@ -553,6 +1127,7 @@ export function createFramerAgentToolRuntime({
553
1127
 
554
1128
  const ensureHostedSetup = async () => {
555
1129
  if (env.DEXTER_HOSTED_FRAMER_AUTO_SETUP !== 'true') return;
1130
+ if (hasInstalledFramerSkill(env)) return;
556
1131
  if (!hostedSetupPromise) {
557
1132
  reportActivity({
558
1133
  kind: 'status_update',
@@ -578,7 +1153,7 @@ export function createFramerAgentToolRuntime({
578
1153
  );
579
1154
  };
580
1155
 
581
- const acquireProjectAuthorization = async ({ forceRefresh = false } = {}) => {
1156
+ const resolveProjectAuthorization = async () => {
582
1157
  if (typeof authorizeProject !== 'function') {
583
1158
  throw toolError(
584
1159
  'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
@@ -589,7 +1164,7 @@ export function createFramerAgentToolRuntime({
589
1164
  let authorization = await authorizeProject({
590
1165
  projectId,
591
1166
  initiate: true,
592
- forceRefresh,
1167
+ forceRefresh: false,
593
1168
  signal: abortController.signal,
594
1169
  });
595
1170
  if (authorization?.framerProjectId !== projectId) {
@@ -600,52 +1175,17 @@ export function createFramerAgentToolRuntime({
600
1175
  );
601
1176
  }
602
1177
  if (authorization?.status === 'authorization_required') {
603
- const url = authorizationUrl(authorization.authUrl);
604
1178
  reportActivity({
605
1179
  kind: 'status_update',
606
1180
  source: 'framer-agent',
607
1181
  message:
608
- 'Approve access to this Framer project in the browser. Dexter will continue automatically.',
1182
+ 'This Framer project needs a project API key before Dexter can continue.',
609
1183
  });
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,
1184
+ throw toolError(
1185
+ 'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
1186
+ 'Connect this Framer project in Dexter by adding a project API key, then retry.',
1187
+ 409,
627
1188
  );
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
1189
  }
650
1190
  const apiKey = String(authorization?.apiKey || '').trim();
651
1191
  if (authorization?.status !== 'ready' || !apiKey) {
@@ -655,13 +1195,21 @@ export function createFramerAgentToolRuntime({
655
1195
  409,
656
1196
  );
657
1197
  }
658
- await executeCli(['project', 'auth', projectId, apiKey], {
1198
+ const authorizedProjectUrl = normalizeProjectUrl(
1199
+ authorization.framerProjectUrl,
1200
+ projectId,
1201
+ );
1202
+ return { ...authorization, apiKey, framerProjectUrl: authorizedProjectUrl };
1203
+ };
1204
+
1205
+ const installProjectAuthorization = async (authorization) => {
1206
+ await executeCli(['project', 'auth', projectId, authorization.apiKey], {
659
1207
  timeoutMs: 30_000,
660
1208
  });
661
1209
  reportActivity({
662
1210
  kind: 'status_update',
663
1211
  source: 'framer-agent',
664
- message: 'This Framer project is authorized for the local harness.',
1212
+ message: 'This Framer project is authorized for the coding harness.',
665
1213
  });
666
1214
  };
667
1215
 
@@ -671,14 +1219,55 @@ export function createFramerAgentToolRuntime({
671
1219
  });
672
1220
  };
673
1221
 
1222
+ const reportRejectedAuthorization = async (authorization, error) => {
1223
+ if (
1224
+ typeof verifyProjectAuthorization !== 'function'
1225
+ || !authorization?.credentialVersion
1226
+ ) return;
1227
+ await verifyProjectAuthorization({
1228
+ status: 'rejected',
1229
+ framerProjectId: projectId,
1230
+ framerProjectUrl: projectUrl,
1231
+ credentialVersion: authorization.credentialVersion,
1232
+ errorCode: String(error?.code || 'FRAMER_AGENT_AUTHORIZATION_REJECTED')
1233
+ .slice(0, 120),
1234
+ signal: abortController.signal,
1235
+ }).catch(() => undefined);
1236
+ };
1237
+
1238
+ const verifyConnectedProject = async (activeSessionId, authorization) => {
1239
+ const result = await executeCli(['exec', '-s', activeSessionId], {
1240
+ input: [
1241
+ 'const [project, branch] = await Promise.all([',
1242
+ ' framer.getProjectInfo(),',
1243
+ ' framer.agent.getActiveBranch(),',
1244
+ ']);',
1245
+ 'console.log(JSON.stringify({ projectName: project?.name ?? null, projectUrl: branch?.url ?? null }));',
1246
+ ].join('\n'),
1247
+ timeoutMs: 30_000,
1248
+ });
1249
+ const observed = parseStructuredOutput(result.stdout);
1250
+ const observedProjectUrl = normalizeProjectUrl(
1251
+ observed?.projectUrl,
1252
+ projectId,
1253
+ );
1254
+ if (
1255
+ typeof verifyProjectAuthorization === 'function'
1256
+ && authorization?.credentialVersion
1257
+ ) {
1258
+ await verifyProjectAuthorization({
1259
+ status: 'verified',
1260
+ framerProjectId: projectId,
1261
+ framerProjectUrl: observedProjectUrl,
1262
+ credentialVersion: authorization.credentialVersion,
1263
+ signal: abortController.signal,
1264
+ });
1265
+ }
1266
+ };
1267
+
674
1268
  const ensureSession = async () => {
675
1269
  await ensureHostedSetup();
676
1270
  if (sessionId) return sessionId;
677
- const cached = sessionsByProject.get(projectId);
678
- if (cached) {
679
- sessionId = cached;
680
- return sessionId;
681
- }
682
1271
  if (sessionPromise) return sessionPromise;
683
1272
  sessionPromise = (async () => {
684
1273
  reportActivity({
@@ -686,23 +1275,49 @@ export function createFramerAgentToolRuntime({
686
1275
  source: 'framer-agent',
687
1276
  message: 'Connecting the coding harness to this Framer project.',
688
1277
  });
689
- if (!(await projectIsAuthorizedLocally())) {
690
- await acquireProjectAuthorization();
1278
+ let authorization = null;
1279
+ if (typeof authorizeProject === 'function') {
1280
+ authorization = await resolveProjectAuthorization();
1281
+ }
1282
+
1283
+ const cached = sessionsByProject.get(sessionCacheKey);
1284
+ if (
1285
+ cached
1286
+ && (
1287
+ typeof authorizeProject !== 'function'
1288
+ || (
1289
+ authorization?.verificationStatus === 'verified'
1290
+ && cached.credentialVersion === authorization.credentialVersion
1291
+ )
1292
+ )
1293
+ ) {
1294
+ sessionId = cached.sessionId;
1295
+ return sessionId;
1296
+ }
1297
+ if (cached) {
1298
+ await executeCli(['session', 'destroy', cached.sessionId], {
1299
+ timeoutMs: 10_000,
1300
+ }).catch(() => undefined);
1301
+ sessionsByProject.delete(sessionCacheKey);
1302
+ }
1303
+ if (!authorization && !(await projectIsAuthorizedLocally())) {
1304
+ throw toolError(
1305
+ 'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
1306
+ 'This Framer project needs to be connected before Dexter can use it.',
1307
+ 409,
1308
+ );
691
1309
  }
692
1310
  let created;
693
1311
  try {
1312
+ if (authorization) {
1313
+ await installProjectAuthorization(authorization);
1314
+ }
694
1315
  created = await createSession();
695
1316
  } catch (error) {
696
1317
  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);
1318
+ await reportRejectedAuthorization(authorization, error);
705
1319
  }
1320
+ throw projectConnectionError(error);
706
1321
  }
707
1322
  sessionId = sessionIdFromOutput(created.stdout);
708
1323
  if (!sessionId) {
@@ -721,7 +1336,25 @@ export function createFramerAgentToolRuntime({
721
1336
  503,
722
1337
  );
723
1338
  }
724
- sessionsByProject.set(projectId, sessionId);
1339
+ try {
1340
+ await verifyConnectedProject(sessionId, authorization);
1341
+ } catch (error) {
1342
+ await executeCli(['session', 'destroy', sessionId], {
1343
+ timeoutMs: 10_000,
1344
+ }).catch(() => undefined);
1345
+ sessionId = null;
1346
+ if (
1347
+ isAuthorizationFailure(error)
1348
+ || error?.code === 'FRAMER_AGENT_PROJECT_MISMATCH'
1349
+ ) {
1350
+ await reportRejectedAuthorization(authorization, error);
1351
+ }
1352
+ throw projectConnectionError(error);
1353
+ }
1354
+ sessionsByProject.set(sessionCacheKey, {
1355
+ sessionId,
1356
+ credentialVersion: authorization?.credentialVersion || null,
1357
+ });
725
1358
  reportActivity({
726
1359
  kind: 'status_update',
727
1360
  source: 'framer-agent',
@@ -734,6 +1367,86 @@ export function createFramerAgentToolRuntime({
734
1367
  return sessionPromise;
735
1368
  };
736
1369
 
1370
+ const loadProjectGuidance = async () => {
1371
+ await ensureSession();
1372
+ const skillRoot = framerSkillRoot(env);
1373
+ if (!skillRoot) return { content: '', ...guidanceMetadata };
1374
+ const projectRoot = path.join(skillRoot, 'projects', projectId);
1375
+ if (!fs.existsSync(projectRoot)) {
1376
+ return { content: '', ...guidanceMetadata };
1377
+ }
1378
+ const requestedFiles = [
1379
+ { root: skillRoot, path: 'SKILL.md', label: 'SKILL.md' },
1380
+ { root: projectRoot, path: 'index.md', label: 'project/index.md' },
1381
+ {
1382
+ root: projectRoot,
1383
+ path: 'project-inventory.md',
1384
+ label: 'project/project-inventory.md',
1385
+ },
1386
+ { root: projectRoot, path: 'prompt/core-principles.md' },
1387
+ { root: projectRoot, path: 'prompt/core-examples.md' },
1388
+ { root: projectRoot, path: 'prompt/implementation-strategy.md' },
1389
+ { root: projectRoot, path: 'prompt/updating-the-project.md' },
1390
+ { root: projectRoot, path: 'prompt/tools.md' },
1391
+ { root: projectRoot, path: 'prompt/overview.md' },
1392
+ { root: projectRoot, path: 'prompt/guardrails.md' },
1393
+ { root: projectRoot, path: 'prompt/critical-reminders.md' },
1394
+ ...(visualVerificationRequired
1395
+ ? [
1396
+ { root: projectRoot, path: 'prompt/design-rules.md' },
1397
+ { root: projectRoot, path: 'prompt/how-projects-work.md' },
1398
+ ]
1399
+ : []),
1400
+ ];
1401
+ const files = [];
1402
+ let remainingBytes = MAX_GUIDANCE_BYTES;
1403
+ for (const requestedFile of requestedFiles) {
1404
+ const loaded = readGuidanceFile(
1405
+ requestedFile.root,
1406
+ requestedFile.path,
1407
+ remainingBytes,
1408
+ );
1409
+ if (!loaded) continue;
1410
+ files.push({
1411
+ ...loaded,
1412
+ path: requestedFile.label || requestedFile.path,
1413
+ });
1414
+ remainingBytes -= Buffer.byteLength(loaded.content, 'utf8');
1415
+ if (remainingBytes <= 0) break;
1416
+ }
1417
+ const content = files
1418
+ .map((file) => `## ${file.path}\n\n${file.content}`)
1419
+ .join('\n\n');
1420
+ const inventory = files.find(
1421
+ (file) => file.path.endsWith('project-inventory.md'),
1422
+ )?.content || '';
1423
+ guidanceMetadata = {
1424
+ loaded: Boolean(content),
1425
+ contentHash: content
1426
+ ? crypto.createHash('sha256').update(content).digest('hex')
1427
+ : null,
1428
+ projectContextHash: inventory
1429
+ ? crypto.createHash('sha256').update(inventory).digest('hex')
1430
+ : null,
1431
+ files: files.map((file) => file.path),
1432
+ bytes: Buffer.byteLength(content, 'utf8'),
1433
+ };
1434
+ return { content, ...guidanceMetadata };
1435
+ };
1436
+
1437
+ const configurePromptContext = ({
1438
+ doctrineLoaded = false,
1439
+ doctrineVersion = null,
1440
+ } = {}) => {
1441
+ promptMetadata = {
1442
+ doctrineLoaded: doctrineLoaded === true,
1443
+ doctrineVersion:
1444
+ typeof doctrineVersion === 'string' && doctrineVersion.trim()
1445
+ ? doctrineVersion.trim().slice(0, 160)
1446
+ : null,
1447
+ };
1448
+ };
1449
+
737
1450
  const execCode = async (code, { timeoutMs } = {}) => {
738
1451
  let activeSessionId = await ensureSession();
739
1452
  try {
@@ -750,7 +1463,7 @@ export function createFramerAgentToolRuntime({
750
1463
  ) {
751
1464
  throw error;
752
1465
  }
753
- sessionsByProject.delete(projectId);
1466
+ sessionsByProject.delete(sessionCacheKey);
754
1467
  sessionId = null;
755
1468
  activeSessionId = await ensureSession();
756
1469
  return executeCli(['exec', '-s', activeSessionId], {
@@ -816,7 +1529,9 @@ export function createFramerAgentToolRuntime({
816
1529
  const normalizedResult = (result) => {
817
1530
  const parsed = parseStructuredOutput(result.stdout);
818
1531
  return {
819
- ...(parsed === null ? { output: boundedText(result.stdout, 200_000) } : { result: parsed }),
1532
+ ...(parsed === null
1533
+ ? { output: boundedText(result.stdout, MAX_MODEL_RESULT_BYTES) }
1534
+ : { result: boundedStructuredValue(parsed) }),
820
1535
  ...(result.stderr.trim()
821
1536
  ? { diagnostics: boundedText(result.stderr, 20_000) }
822
1537
  : {}),
@@ -824,19 +1539,315 @@ export function createFramerAgentToolRuntime({
824
1539
  };
825
1540
  };
826
1541
 
827
- const markInspection = () => {
1542
+ const markInspection = ({ visual = false, verifies = [] } = {}) => {
828
1543
  operationSequence += 1;
829
1544
  lastInspectionSequence = operationSequence;
830
1545
  lastInspectionAt = new Date().toISOString();
1546
+ verificationEvidence.set('structural', operationSequence);
1547
+ if (visual) {
1548
+ screenshotCalls += 1;
1549
+ lastScreenshotSequence = operationSequence;
1550
+ verificationEvidence.set('visual', operationSequence);
1551
+ if (lastMutationSequence === 0) screenshotBeforeMutation = true;
1552
+ }
1553
+ for (const check of normalizeVerificationValues(
1554
+ verifies,
1555
+ GENERIC_VERIFICATION_CHECKS,
1556
+ )) {
1557
+ verificationEvidence.set(check, operationSequence);
1558
+ }
831
1559
  };
832
1560
 
833
1561
  const markMutation = () => {
834
1562
  operationSequence += 1;
835
1563
  lastMutationSequence = operationSequence;
1564
+ lastMutationAt = new Date().toISOString();
836
1565
  };
837
1566
 
1567
+ const requiredVerificationChecks = () =>
1568
+ taskPlan?.verification || [];
1569
+ const taskPlanSnapshot = () => {
1570
+ if (!taskPlan) return null;
1571
+ return {
1572
+ summary: taskPlan.summary,
1573
+ domains: [...taskPlan.domains],
1574
+ verification: [...taskPlan.verification],
1575
+ ...(taskPlan.behaviors.length > 0
1576
+ ? {
1577
+ behaviors: taskPlan.behaviors.map((behavior) => ({
1578
+ ...behavior,
1579
+ expectedContent: [...behavior.expectedContent],
1580
+ })),
1581
+ mechanisms: taskPlan.mechanisms.map((decision) => ({
1582
+ ...decision,
1583
+ })),
1584
+ rejectedMechanisms: taskPlan.rejectedMechanisms.map((entry) => ({
1585
+ ...entry,
1586
+ })),
1587
+ compatibility: taskPlan.compatibility.map((entry) => ({
1588
+ ...entry,
1589
+ requiredCapabilities: [...entry.requiredCapabilities],
1590
+ })),
1591
+ }
1592
+ : {}),
1593
+ };
1594
+ };
838
1595
  const synchronized = () =>
839
1596
  mutationCalls === 0 || lastInspectionSequence > lastMutationSequence;
1597
+ const verificationCheckCompleted = (check) => {
1598
+ if (mutationCalls === 0) return true;
1599
+ if (check === 'structural') return synchronized();
1600
+ if (check === 'visual') {
1601
+ return (
1602
+ screenshotBeforeMutation
1603
+ && lastScreenshotSequence > lastMutationSequence
1604
+ );
1605
+ }
1606
+ return Number(verificationEvidence.get(check) || 0) > lastMutationSequence;
1607
+ };
1608
+ const visualVerificationRequired = () =>
1609
+ applyChangesCalls > 0
1610
+ || requiredVerificationChecks().includes('visual');
1611
+ const visualVerificationCompleted = () =>
1612
+ !visualVerificationRequired()
1613
+ || mutationCalls === 0
1614
+ || (
1615
+ screenshotBeforeMutation
1616
+ && lastScreenshotSequence > lastMutationSequence
1617
+ );
1618
+ const missingVerificationChecks = () => {
1619
+ if (mutationCalls === 0) return [];
1620
+ const missing = [];
1621
+ if (!taskPlan) missing.push('task-plan');
1622
+ const required = new Set([
1623
+ 'structural',
1624
+ ...requiredVerificationChecks(),
1625
+ ...(applyChangesCalls > 0 ? ['visual'] : []),
1626
+ ]);
1627
+ for (const check of required) {
1628
+ if (!verificationCheckCompleted(check)) missing.push(check);
1629
+ }
1630
+ return missing;
1631
+ };
1632
+ const verification = () => ({
1633
+ ownedBy: 'harness',
1634
+ required: mutationCalls > 0,
1635
+ completed: missingVerificationChecks().length === 0,
1636
+ structuralCompleted: synchronized(),
1637
+ visualRequired: visualVerificationRequired() && mutationCalls > 0,
1638
+ visualCompleted: visualVerificationCompleted(),
1639
+ screenshotCalls,
1640
+ ...(requiredVerificationChecks().includes('interactions')
1641
+ || interactionVerificationCalls > 0
1642
+ ? {
1643
+ interactionVerificationCalls,
1644
+ interactionVerification: {
1645
+ completed: verificationCheckCompleted('interactions'),
1646
+ verificationLevel:
1647
+ verificationCheckCompleted('interactions')
1648
+ ? interactionVerification.verificationLevel
1649
+ : 'unverified',
1650
+ checks: interactionVerification.checks.map((check) => ({
1651
+ ...check,
1652
+ failures: [...check.failures],
1653
+ })),
1654
+ behaviors: interactionVerification.behaviors.map((behavior) => ({
1655
+ ...behavior,
1656
+ failures: [...behavior.failures],
1657
+ })),
1658
+ verifiedAt: interactionVerification.verifiedAt,
1659
+ },
1660
+ }
1661
+ : {}),
1662
+ taskPlan: taskPlanSnapshot(),
1663
+ requiredChecks: [
1664
+ ...new Set([
1665
+ 'structural',
1666
+ ...requiredVerificationChecks(),
1667
+ ...(applyChangesCalls > 0 ? ['visual'] : []),
1668
+ ]),
1669
+ ],
1670
+ completedChecks: [
1671
+ ...new Set([
1672
+ 'structural',
1673
+ ...requiredVerificationChecks(),
1674
+ ...(applyChangesCalls > 0 ? ['visual'] : []),
1675
+ ]),
1676
+ ].filter(verificationCheckCompleted),
1677
+ missingChecks: missingVerificationChecks(),
1678
+ lastMutationAt,
1679
+ lastInspectionAt,
1680
+ });
1681
+
1682
+ const inspectFinalState = async () => {
1683
+ await serial;
1684
+ return {
1685
+ synchronized: synchronized(),
1686
+ sourceHash: null,
1687
+ changed: mutationCalls > 0,
1688
+ mutationCalls,
1689
+ readCalls,
1690
+ branch: branchChange,
1691
+ lastInspectionAt,
1692
+ lastMutationAt,
1693
+ taskPlan: taskPlanSnapshot(),
1694
+ verification: verification(),
1695
+ };
1696
+ };
1697
+
1698
+ const finalizeCompletion = (completion) => {
1699
+ if (
1700
+ completion?.status !== 'completed'
1701
+ || mutationCalls === 0
1702
+ || verification().completed
1703
+ ) {
1704
+ return completion;
1705
+ }
1706
+ const missing = missingVerificationChecks().map((check) => {
1707
+ if (check === 'task-plan') return 'a harness task and verification plan';
1708
+ if (check === 'structural') return 'a final structural read';
1709
+ if (check === 'visual') return 'before-and-after screenshot verification';
1710
+ return `post-mutation ${check} verification`;
1711
+ });
1712
+ const reason =
1713
+ `The harness ended before completing ${missing.join(' and ')}.`;
1714
+ return {
1715
+ ...completion,
1716
+ status: 'failed',
1717
+ summary: `${String(completion?.summary || '').trim()} ${reason}`.trim(),
1718
+ checks: [
1719
+ ...(Array.isArray(completion?.checks) ? completion.checks : []),
1720
+ {
1721
+ command: 'Harness-owned final verification',
1722
+ status: 'failed',
1723
+ output: reason,
1724
+ },
1725
+ ],
1726
+ blockedReason: null,
1727
+ };
1728
+ };
1729
+
1730
+ const verificationRepairPrompt = () => {
1731
+ const current = verification();
1732
+ if (current.completed || mutationCalls === 0) return null;
1733
+ const missing = [];
1734
+ for (const check of current.missingChecks || []) {
1735
+ if (check === 'task-plan') {
1736
+ missing.push(
1737
+ 'declare the task domains and verification plan with framer_plan_task',
1738
+ );
1739
+ } else if (check === 'structural') {
1740
+ missing.push('perform a focused live read after the latest mutation');
1741
+ } else if (check === 'visual') {
1742
+ missing.push(
1743
+ screenshotBeforeMutation
1744
+ ? 'capture and inspect the affected canvas after the latest mutation'
1745
+ : 'report that the required baseline screenshot was missed; do not claim visual verification',
1746
+ );
1747
+ } else {
1748
+ missing.push(
1749
+ check === 'interactions'
1750
+ ? 'call framer_verify_interactions with canonical evidence node IDs for every declared behavior and chosen mechanism'
1751
+ : `perform a focused read tagged with verifies:["${check}"] after the latest mutation`,
1752
+ );
1753
+ }
1754
+ }
1755
+ return [
1756
+ 'Continue the same Framer task from its current project state.',
1757
+ 'Do not redo successful work.',
1758
+ `Before returning, ${missing.join(' and ')}.`,
1759
+ 'Repair only concrete problems you observe, then return the required JSON outcome.',
1760
+ ].join(' ');
1761
+ };
1762
+
1763
+ const builtInInstructions = (documentationUnavailable = false) => ({
1764
+ stdout: JSON.stringify({
1765
+ source: documentationUnavailable
1766
+ ? 'built-in-fallback'
1767
+ : 'built-in',
1768
+ guidance: [
1769
+ 'Use framer_read with framer.agent.getNode({ id }) or getNodes({ ids }) for node inspection.',
1770
+ 'Use framer_read_project for supported project queries such as screenshots.',
1771
+ 'Use framer_query_images for approved stock imagery only after checking user attachments and suitable existing project images.',
1772
+ 'Use framer_apply_changes for layout and styling. Read its diagnostics, verify the result, and repair concrete issues.',
1773
+ 'If framer_write is rejected as unsafe, do not retry JavaScript variants. Switch to framer_apply_changes or report the concrete blocker.',
1774
+ 'Batch related work when practical, reuse evidence already collected, and stop when the requested outcome is satisfied.',
1775
+ ],
1776
+ documentationUnavailable,
1777
+ }),
1778
+ stderr: '',
1779
+ });
1780
+
1781
+ const reserveToolCall = (name, category) => {
1782
+ toolCallCount += 1;
1783
+ if (category === 'progress') progressCalls += 1;
1784
+ if (category === 'read') readCalls += 1;
1785
+ const call = {
1786
+ sequence: toolCallCount,
1787
+ name,
1788
+ category,
1789
+ status: 'failed',
1790
+ durationMs: 0,
1791
+ };
1792
+ toolCalls.push(call);
1793
+ return call;
1794
+ };
1795
+
1796
+ const callFingerprint = (name, rawArguments) =>
1797
+ crypto
1798
+ .createHash('sha256')
1799
+ .update(`${name}\n${JSON.stringify(rawArguments)}`)
1800
+ .digest('hex');
1801
+
1802
+ const requireTaskPlanForMutation = ({ canvas = false } = {}) => {
1803
+ if (!taskPlan) {
1804
+ throw toolError(
1805
+ 'FRAMER_HARNESS_PLAN_REQUIRED',
1806
+ 'Inspect the live Framer target, then call framer_plan_task before making a mutation.',
1807
+ );
1808
+ }
1809
+ if (
1810
+ (canvas || taskPlan.verification.includes('visual'))
1811
+ && !screenshotBeforeMutation
1812
+ ) {
1813
+ throw toolError(
1814
+ 'FRAMER_HARNESS_BASELINE_SCREENSHOT_REQUIRED',
1815
+ 'Capture and inspect a screenshot of the affected canvas before the first visual mutation.',
1816
+ );
1817
+ }
1818
+ const rejectedSelectedMechanisms = taskPlan.behaviors.filter((behavior) => {
1819
+ const selected = taskPlan.mechanisms.find((decision) =>
1820
+ decision.behaviorId === behavior.id);
1821
+ return selected && taskPlan.rejectedMechanisms.some((rejected) =>
1822
+ rejected.mechanism === selected.mechanism
1823
+ && (
1824
+ rejected.behaviorId === behavior.id
1825
+ || rejected.behaviorSignature === behavior.signature
1826
+ ));
1827
+ });
1828
+ if (rejectedSelectedMechanisms.length > 0) {
1829
+ throw toolError(
1830
+ 'FRAMER_BEHAVIOR_MECHANISM_REPLAN_REQUIRED',
1831
+ `Choose a different mechanism for ${rejectedSelectedMechanisms.map((behavior) => behavior.id).join(', ')} before making another mutation.`,
1832
+ );
1833
+ }
1834
+ if (mutationCalls > 0) {
1835
+ const pendingRepresentative = taskPlan.behaviors
1836
+ .filter((behavior) => behavior.expectedTargets > 1)
1837
+ .filter((behavior) =>
1838
+ taskPlan.mechanisms.find((decision) =>
1839
+ decision.behaviorId === behavior.id
1840
+ && decision.representativeFirst))
1841
+ .filter((behavior) =>
1842
+ !representativeVerifiedBehaviorIds.has(behavior.id));
1843
+ if (pendingRepresentative.length > 0) {
1844
+ throw toolError(
1845
+ 'FRAMER_BEHAVIOR_REPRESENTATIVE_VERIFICATION_REQUIRED',
1846
+ `Verify one representative implementation for ${pendingRepresentative.map((behavior) => behavior.id).join(', ')} before batching further mutations.`,
1847
+ );
1848
+ }
1849
+ }
1850
+ };
840
1851
 
841
1852
  async function invokeUnsafe(name, rawArguments = {}) {
842
1853
  if (closed) {
@@ -857,6 +1868,25 @@ export function createFramerAgentToolRuntime({
857
1868
  'Framer Agent tool arguments must be an object.',
858
1869
  );
859
1870
  }
1871
+ const mutation =
1872
+ name === 'framer_apply_changes' || name === 'framer_write';
1873
+ const category =
1874
+ name === 'framer_plan_task'
1875
+ ? 'plan'
1876
+ : mutation
1877
+ ? 'mutation'
1878
+ : 'read';
1879
+ const fingerprint = callFingerprint(name, rawArguments);
1880
+ if (
1881
+ repeatedFailureFingerprint === fingerprint
1882
+ && repeatedFailureCount >= 5
1883
+ ) {
1884
+ repeatedFailureStops += 1;
1885
+ throw toolError(
1886
+ 'FRAMER_AGENT_REPEATED_CALL_LOOP',
1887
+ 'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
1888
+ );
1889
+ }
860
1890
  if (name === 'progress_update') {
861
1891
  const message = String(rawArguments.message || '').trim().slice(0, 500);
862
1892
  if (!message) {
@@ -865,15 +1895,20 @@ export function createFramerAgentToolRuntime({
865
1895
  'A progress message is required.',
866
1896
  );
867
1897
  }
1898
+ const toolCall = reserveToolCall(name, 'progress');
868
1899
  reportActivity({
869
1900
  kind: 'status_update',
870
1901
  source: 'harness',
871
1902
  message,
872
1903
  });
1904
+ repeatedFailureFingerprint = null;
1905
+ repeatedFailureCount = 0;
1906
+ toolCall.status = 'succeeded';
873
1907
  return { acknowledged: true };
874
1908
  }
875
1909
 
876
1910
  const startedAt = Date.now();
1911
+ const toolCall = reserveToolCall(name, category);
877
1912
  reportActivity({
878
1913
  kind: 'tool_active',
879
1914
  source: 'framer-agent',
@@ -882,17 +1917,48 @@ export function createFramerAgentToolRuntime({
882
1917
  message:
883
1918
  name === 'framer_apply_changes' || name === 'framer_write'
884
1919
  ? 'Updating the Framer project'
1920
+ : name === 'framer_plan_task'
1921
+ ? 'Planning the Framer work and verification'
885
1922
  : 'Inspecting the Framer project',
886
1923
  });
887
1924
  try {
888
1925
  let result;
889
- if (name === 'framer_instructions') {
890
- readCalls += 1;
891
- result = await execCode(
892
- 'console.log(await framer.agent.getSystemPrompt())',
1926
+ if (name === 'framer_write' && lowLevelWriteDisabled) {
1927
+ lowLevelWriteBlockedCalls += 1;
1928
+ throw toolError(
1929
+ 'FRAMER_AGENT_WRITE_FALLBACK_REQUIRED',
1930
+ 'Low-level JavaScript writes are disabled for this run after repeated safety rejections. Use framer_apply_changes, inspect the project, or report the concrete blocker.',
893
1931
  );
1932
+ } else if (name === 'framer_instructions') {
1933
+ const queries = Array.isArray(rawArguments.queries)
1934
+ ? uniqueStrings(rawArguments.queries, 8)
1935
+ : [];
1936
+ if (queries.length > 0 && !instructionLookupUnavailable) {
1937
+ try {
1938
+ result = await executeCli(
1939
+ ['docs', ...queries],
1940
+ { timeoutMs: 30_000 },
1941
+ );
1942
+ } catch (error) {
1943
+ instructionFallbacks += 1;
1944
+ instructionLookupUnavailable = true;
1945
+ trace?.warn?.('framer_agent_docs_fallback', {
1946
+ code: error?.code,
1947
+ message: error?.message,
1948
+ });
1949
+ reportActivity({
1950
+ kind: 'status_update',
1951
+ source: 'framer-agent',
1952
+ message:
1953
+ 'Targeted Framer documentation is unavailable, so I am continuing with the built-in harness guidance.',
1954
+ });
1955
+ result = builtInInstructions(true);
1956
+ }
1957
+ } else {
1958
+ if (queries.length > 0) instructionFallbacks += 1;
1959
+ result = builtInInstructions(queries.length > 0);
1960
+ }
894
1961
  } else if (name === 'framer_context') {
895
- readCalls += 1;
896
1962
  result = await execCode(
897
1963
  [
898
1964
  'const [context, branch] = await Promise.all([',
@@ -903,8 +1969,210 @@ export function createFramerAgentToolRuntime({
903
1969
  ].join('\n'),
904
1970
  );
905
1971
  markInspection();
1972
+ } else if (name === 'framer_plan_task') {
1973
+ if (lastInspectionSequence === 0) {
1974
+ throw toolError(
1975
+ 'FRAMER_HARNESS_INSPECTION_REQUIRED',
1976
+ 'Inspect the live Framer target before declaring the task and verification plan.',
1977
+ );
1978
+ }
1979
+ const nextPlan = normalizeTaskPlan(
1980
+ rawArguments,
1981
+ inheritedRejectedMechanisms,
1982
+ );
1983
+ if (
1984
+ mutationCalls > 0
1985
+ && nextPlan.verification.includes('visual')
1986
+ && !screenshotBeforeMutation
1987
+ ) {
1988
+ throw toolError(
1989
+ 'FRAMER_HARNESS_BASELINE_SCREENSHOT_REQUIRED',
1990
+ 'Visual verification cannot be added after mutations when no baseline screenshot was captured.',
1991
+ );
1992
+ }
1993
+ const previousMechanisms = new Map(
1994
+ (taskPlan?.mechanisms || []).map((decision) => [
1995
+ decision.behaviorId,
1996
+ decision.mechanism,
1997
+ ]),
1998
+ );
1999
+ const behaviorPlan = mergeBehaviorPlans(taskPlan, nextPlan);
2000
+ taskPlan = {
2001
+ summary: nextPlan.summary,
2002
+ domains: uniqueStrings([
2003
+ ...(taskPlan?.domains || []),
2004
+ ...nextPlan.domains,
2005
+ ]),
2006
+ verification: uniqueStrings([
2007
+ ...(taskPlan?.verification || []),
2008
+ ...nextPlan.verification,
2009
+ ]),
2010
+ ...behaviorPlan,
2011
+ };
2012
+ for (const decision of taskPlan.mechanisms) {
2013
+ const prior = previousMechanisms.get(decision.behaviorId);
2014
+ if (prior && prior !== decision.mechanism) {
2015
+ representativeVerifiedBehaviorIds.delete(decision.behaviorId);
2016
+ }
2017
+ }
2018
+ result = {
2019
+ stdout: JSON.stringify({
2020
+ plan: {
2021
+ summary: taskPlan.summary,
2022
+ domains: [...taskPlan.domains],
2023
+ verification: [...taskPlan.verification],
2024
+ behaviors: taskPlan.behaviors.map((behavior) => ({
2025
+ ...behavior,
2026
+ expectedContent: [...behavior.expectedContent],
2027
+ })),
2028
+ mechanisms: taskPlan.mechanisms.map((decision) => ({
2029
+ ...decision,
2030
+ })),
2031
+ compatibility: taskPlan.compatibility.map((entry) => ({
2032
+ ...entry,
2033
+ requiredCapabilities: [...entry.requiredCapabilities],
2034
+ })),
2035
+ rejectedMechanisms: taskPlan.rejectedMechanisms.map((entry) => ({
2036
+ ...entry,
2037
+ })),
2038
+ },
2039
+ instruction:
2040
+ 'Implement one representative first when required, verify it, then complete coverage and gather every derived verification result after the final mutation.',
2041
+ }),
2042
+ stderr: '',
2043
+ };
2044
+ } else if (name === 'framer_verify_interactions') {
2045
+ if (mutationCalls === 0) {
2046
+ throw toolError(
2047
+ 'FRAMER_HARNESS_INTERACTION_MUTATION_REQUIRED',
2048
+ 'Verify interactions only after the relevant Framer mutation has succeeded.',
2049
+ );
2050
+ }
2051
+ if (!taskPlan?.behaviors?.length) {
2052
+ throw toolError(
2053
+ 'FRAMER_BEHAVIOR_CONTRACT_REQUIRED',
2054
+ 'Declare observable behaviors and compatible mechanisms with framer_plan_task before interaction verification.',
2055
+ );
2056
+ }
2057
+ const checks = normalizeBehaviorEvidence(
2058
+ rawArguments.checks,
2059
+ taskPlan,
2060
+ );
2061
+ const pagePath = normalizePagePath(rawArguments.pagePath);
2062
+ const requestedIds = evidenceNodeIds(checks);
2063
+ const liveResult = await execCode(
2064
+ [
2065
+ `const requestedIds = ${JSON.stringify(requestedIds)};`,
2066
+ 'const nodes = await Promise.all(requestedIds.map(async (id) => ({',
2067
+ ' id,',
2068
+ ' node: await framer.agent.serialize({ id, depth: 8 },',
2069
+ ` { pagePath: ${JSON.stringify(pagePath)} }).catch(() => null),`,
2070
+ ' parentId: await framer.agent.getParentNode({ id },',
2071
+ ` { pagePath: ${JSON.stringify(pagePath)} }).then((node) => node?.id ?? null).catch(() => null),`,
2072
+ ' ancestorIds: await framer.agent.getAncestors({ id },',
2073
+ ` { pagePath: ${JSON.stringify(pagePath)} }).then((nodes) => Array.isArray(nodes) ? nodes.map((node) => node?.id).filter(Boolean) : []).catch(() => []),`,
2074
+ '})));',
2075
+ 'console.log(JSON.stringify({ nodes }, null, 2));',
2076
+ ].join('\n'),
2077
+ );
2078
+ const parsed = parseStructuredOutput(liveResult.stdout);
2079
+ const nodesById = new Map(
2080
+ (Array.isArray(parsed?.nodes) ? parsed.nodes : [])
2081
+ .filter((entry) => isRecord(entry) && typeof entry.id === 'string')
2082
+ .map((entry) => [entry.id, {
2083
+ node: entry.node || null,
2084
+ parentId:
2085
+ typeof entry.parentId === 'string'
2086
+ ? entry.parentId
2087
+ : null,
2088
+ ancestorIds: Array.isArray(entry.ancestorIds)
2089
+ ? entry.ancestorIds
2090
+ : [],
2091
+ }]),
2092
+ );
2093
+ const verified = verifyBehaviorEvidence({
2094
+ plan: taskPlan,
2095
+ checks,
2096
+ snapshots: nodesById,
2097
+ context: {
2098
+ lowLevelWriteCalls,
2099
+ authorizedInstanceContainerIds,
2100
+ },
2101
+ });
2102
+ markInspection();
2103
+ interactionVerificationCalls += 1;
2104
+ interactionVerification = {
2105
+ ...verified,
2106
+ verifiedAt: new Date().toISOString(),
2107
+ };
2108
+ for (const behaviorResult of verified.behaviors) {
2109
+ const failedChecks = verified.checks.filter((check) =>
2110
+ check.behaviorId === behaviorResult.behaviorId && !check.passed);
2111
+ if (failedChecks.length === 0) {
2112
+ repeatedBehaviorVerificationFailures.delete(
2113
+ behaviorResult.behaviorId,
2114
+ );
2115
+ continue;
2116
+ }
2117
+ const failureFingerprint = crypto
2118
+ .createHash('sha256')
2119
+ .update(JSON.stringify({
2120
+ mechanism: behaviorResult.mechanism,
2121
+ failures: failedChecks
2122
+ .flatMap((check) => check.failures)
2123
+ .sort(),
2124
+ }))
2125
+ .digest('hex');
2126
+ const prior = repeatedBehaviorVerificationFailures.get(
2127
+ behaviorResult.behaviorId,
2128
+ );
2129
+ const count =
2130
+ prior?.fingerprint === failureFingerprint
2131
+ ? prior.count + 1
2132
+ : 1;
2133
+ repeatedBehaviorVerificationFailures.set(
2134
+ behaviorResult.behaviorId,
2135
+ { fingerprint: failureFingerprint, count },
2136
+ );
2137
+ if (count < 2 || !behaviorResult.mechanism) continue;
2138
+ const alreadyRejected = taskPlan.rejectedMechanisms.some((rejected) =>
2139
+ rejected.mechanism === behaviorResult.mechanism
2140
+ && (
2141
+ rejected.behaviorId === behaviorResult.behaviorId
2142
+ || rejected.behaviorSignature
2143
+ === behaviorResult.behaviorSignature
2144
+ ));
2145
+ if (alreadyRejected) continue;
2146
+ taskPlan = {
2147
+ ...taskPlan,
2148
+ rejectedMechanisms: [
2149
+ ...taskPlan.rejectedMechanisms,
2150
+ {
2151
+ behaviorId: behaviorResult.behaviorId,
2152
+ behaviorSignature: behaviorResult.behaviorSignature,
2153
+ mechanism: behaviorResult.mechanism,
2154
+ reason:
2155
+ `Repeated structural verification failed: ${failedChecks
2156
+ .flatMap((check) => check.failures)
2157
+ .join('; ')
2158
+ .slice(0, 700)}`,
2159
+ source: 'verification_failure',
2160
+ },
2161
+ ],
2162
+ };
2163
+ }
2164
+ verified.representativeBehaviorIds.forEach((behaviorId) =>
2165
+ representativeVerifiedBehaviorIds.add(behaviorId));
2166
+ if (interactionVerification.completed) {
2167
+ verificationEvidence.set('interactions', operationSequence);
2168
+ } else {
2169
+ verificationEvidence.delete('interactions');
2170
+ }
2171
+ result = {
2172
+ stdout: JSON.stringify(interactionVerification),
2173
+ stderr: liveResult.stderr,
2174
+ };
906
2175
  } else if (name === 'framer_read_project') {
907
- readCalls += 1;
908
2176
  const queries = Array.isArray(rawArguments.queries)
909
2177
  ? rawArguments.queries.slice(0, 50)
910
2178
  : [];
@@ -921,7 +2189,41 @@ export function createFramerAgentToolRuntime({
921
2189
  'console.log(JSON.stringify(result, null, 2));',
922
2190
  ].join('\n'),
923
2191
  );
924
- markInspection();
2192
+ markInspection({
2193
+ visual: queries.some((query) => query.type === 'screenshot'),
2194
+ verifies: rawArguments.verifies,
2195
+ });
2196
+ } else if (name === 'framer_query_images') {
2197
+ const query = String(rawArguments.query || '').trim();
2198
+ if (query.length < 2) {
2199
+ throw toolError(
2200
+ 'FRAMER_AGENT_IMAGE_QUERY_INVALID',
2201
+ 'A specific image-search query is required.',
2202
+ );
2203
+ }
2204
+ const count = Math.max(
2205
+ 1,
2206
+ Math.min(8, Math.floor(Number(rawArguments.count) || 4)),
2207
+ );
2208
+ const orientation = ['landscape', 'portrait', 'squarish']
2209
+ .includes(rawArguments.orientation)
2210
+ ? rawArguments.orientation
2211
+ : undefined;
2212
+ result = await execCode(
2213
+ [
2214
+ 'const result = await framer.agent.queryImages({',
2215
+ ' source: "unsplash",',
2216
+ ` query: ${JSON.stringify(query)},`,
2217
+ ` count: ${count},`,
2218
+ ...(orientation
2219
+ ? [` orientation: ${JSON.stringify(orientation)},`]
2220
+ : []),
2221
+ '});',
2222
+ 'console.log(JSON.stringify(result, null, 2));',
2223
+ ].join('\n'),
2224
+ { timeoutMs: 30_000 },
2225
+ );
2226
+ imageSearchCalls += 1;
925
2227
  } else if (name === 'framer_apply_changes') {
926
2228
  const changes = String(rawArguments.changes || '').trim();
927
2229
  if (!changes) {
@@ -930,6 +2232,7 @@ export function createFramerAgentToolRuntime({
930
2232
  'Framer Agent changes are required.',
931
2233
  );
932
2234
  }
2235
+ requireTaskPlanForMutation({ canvas: true });
933
2236
  const pagePath = normalizePagePath(rawArguments.pagePath);
934
2237
  result = await execCode(
935
2238
  [
@@ -938,31 +2241,58 @@ export function createFramerAgentToolRuntime({
938
2241
  ].join('\n'),
939
2242
  );
940
2243
  mutationCalls += 1;
2244
+ applyChangesCalls += 1;
941
2245
  markMutation();
2246
+ const parsedResult = parseStructuredOutput(result.stdout);
2247
+ mutationReceipts.push({
2248
+ sequence: operationSequence,
2249
+ kind: 'framer_apply_changes',
2250
+ pagePath,
2251
+ ...summarizeFramerChanges(changes),
2252
+ renamedIds: canonicalIdMappings(parsedResult),
2253
+ });
942
2254
  } else if (name === 'framer_read') {
943
- readCalls += 1;
944
2255
  result = await execCode(
945
2256
  validateCode(rawArguments.code, {
946
2257
  write: false,
947
2258
  allowPublishing,
948
2259
  }),
949
2260
  );
950
- markInspection();
2261
+ markInspection({ verifies: rawArguments.verifies });
951
2262
  } else if (name === 'framer_write') {
952
- result = await execCode(
953
- validateCode(rawArguments.code, {
954
- write: true,
955
- allowPublishing,
956
- }),
957
- );
2263
+ const code = validateCode(rawArguments.code, {
2264
+ write: true,
2265
+ allowPublishing,
2266
+ });
2267
+ const reason = String(rawArguments.reason || '').trim();
2268
+ if (reason.length < 10) {
2269
+ throw toolError(
2270
+ 'FRAMER_AGENT_WRITE_REASON_REQUIRED',
2271
+ 'Explain why this operation cannot be completed with framer_apply_changes before using framer_write.',
2272
+ );
2273
+ }
2274
+ requireTaskPlanForMutation();
2275
+ result = await execCode(code);
958
2276
  mutationCalls += 1;
2277
+ lowLevelWriteCalls += 1;
959
2278
  markMutation();
2279
+ mutationReceipts.push({
2280
+ sequence: operationSequence,
2281
+ kind: 'framer_write',
2282
+ reason: reason.slice(0, 500),
2283
+ codeHash: crypto.createHash('sha256').update(code).digest('hex'),
2284
+ });
960
2285
  } else {
961
2286
  throw toolError(
962
2287
  'FRAMER_AGENT_TOOL_UNKNOWN',
963
2288
  `Unknown Framer Agent tool: ${name}`,
964
2289
  );
965
2290
  }
2291
+ const durationMs = Date.now() - startedAt;
2292
+ repeatedFailureFingerprint = null;
2293
+ repeatedFailureCount = 0;
2294
+ toolCall.status = 'succeeded';
2295
+ toolCall.durationMs = durationMs;
966
2296
  reportActivity({
967
2297
  kind: 'tool_active',
968
2298
  source: 'framer-agent',
@@ -972,10 +2302,54 @@ export function createFramerAgentToolRuntime({
972
2302
  name === 'framer_apply_changes' || name === 'framer_write'
973
2303
  ? 'Updated the Framer project'
974
2304
  : 'Finished inspecting the Framer project',
975
- durationMs: Date.now() - startedAt,
2305
+ durationMs,
976
2306
  });
977
2307
  return normalizedResult(result);
978
2308
  } catch (error) {
2309
+ if (
2310
+ name === 'framer_write'
2311
+ && error?.code === 'FRAMER_AGENT_CODE_UNSAFE'
2312
+ ) {
2313
+ unsafeWriteFailures += 1;
2314
+ if (
2315
+ !lowLevelWriteDisabled
2316
+ && unsafeWriteFailures >= MAX_UNSAFE_WRITE_FAILURES
2317
+ ) {
2318
+ lowLevelWriteDisabled = true;
2319
+ reportActivity({
2320
+ kind: 'status_update',
2321
+ source: 'framer-agent',
2322
+ message:
2323
+ 'Repeated low-level writes were rejected by the safety boundary, so I switched the run to the safer Framer change path.',
2324
+ });
2325
+ }
2326
+ }
2327
+ if (
2328
+ name === 'framer_write'
2329
+ && error?.code === 'FRAMER_AGENT_COMMAND_FAILED'
2330
+ ) {
2331
+ lowLevelWriteFailures += 1;
2332
+ if (
2333
+ !lowLevelWriteDisabled
2334
+ && lowLevelWriteFailures >= MAX_LOW_LEVEL_WRITE_FAILURES
2335
+ ) {
2336
+ lowLevelWriteDisabled = true;
2337
+ reportActivity({
2338
+ kind: 'status_update',
2339
+ source: 'framer-agent',
2340
+ message:
2341
+ 'Repeated low-level Framer writes failed, so I switched the run back to semantic Framer changes and focused inspection.',
2342
+ });
2343
+ }
2344
+ }
2345
+ if (repeatedFailureFingerprint === fingerprint) {
2346
+ repeatedFailureCount += 1;
2347
+ } else {
2348
+ repeatedFailureFingerprint = fingerprint;
2349
+ repeatedFailureCount = 1;
2350
+ }
2351
+ toolCall.durationMs = Date.now() - startedAt;
2352
+ toolCall.errorCode = error?.code || 'FRAMER_AGENT_TOOL_FAILED';
979
2353
  trace?.warn?.('framer_agent_tool_failed', {
980
2354
  name,
981
2355
  code: error?.code,
@@ -1061,31 +2435,96 @@ export function createFramerAgentToolRuntime({
1061
2435
  definitions,
1062
2436
  toolNames: definitions.map((definition) => definition.name),
1063
2437
  preflight: validateAuthoritativeNodeIds,
2438
+ loadProjectGuidance,
2439
+ configurePromptContext,
1064
2440
  invoke,
1065
2441
  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
- }),
2442
+ synchronize: inspectFinalState,
2443
+ inspect: inspectFinalState,
2444
+ finalizeCompletion,
2445
+ verificationRepairPrompt,
1080
2446
  sourceHash: () => null,
1081
2447
  hasPendingChanges: () => false,
1082
2448
  directWorkspace: true,
1083
2449
  summary: () => ({
1084
2450
  projectId,
2451
+ projectUrl,
1085
2452
  mutationCalls,
2453
+ applyChangesCalls,
2454
+ lowLevelWriteCalls,
2455
+ imageSearchCalls,
2456
+ interactionVerificationCalls,
2457
+ mutationReceipts: mutationReceipts.map((receipt) => ({
2458
+ ...receipt,
2459
+ ...(Array.isArray(receipt.commands)
2460
+ ? {
2461
+ commands: receipt.commands.map((command) => ({
2462
+ ...command,
2463
+ ...(Array.isArray(command.eventActions)
2464
+ ? {
2465
+ eventActions: command.eventActions.map((action) => ({
2466
+ ...action,
2467
+ })),
2468
+ }
2469
+ : {}),
2470
+ })),
2471
+ }
2472
+ : {}),
2473
+ ...(receipt.renamedIds
2474
+ ? { renamedIds: { ...receipt.renamedIds } }
2475
+ : {}),
2476
+ })),
1086
2477
  readCalls,
2478
+ progressCalls,
2479
+ toolCallCount,
2480
+ toolDurationMs: toolCalls.reduce(
2481
+ (total, call) => total + call.durationMs,
2482
+ 0,
2483
+ ),
2484
+ toolCalls: toolCalls.map((call) => ({ ...call })),
2485
+ workingPreferences: {
2486
+ focus: workingPreferences.mode,
2487
+ writeScope: workingPreferences.writeScope,
2488
+ preferBatchedMutations:
2489
+ workingPreferences.preferBatchedMutations,
2490
+ avoidRepeatedInspection:
2491
+ workingPreferences.avoidRepeatedInspection,
2492
+ expandScopeOnlyWhenNeeded:
2493
+ workingPreferences.expandScopeOnlyWhenNeeded,
2494
+ },
2495
+ loopProtection: {
2496
+ identicalFailureLimit: 5,
2497
+ blockedCalls: repeatedFailureStops,
2498
+ unsafeWriteFailureLimit: MAX_UNSAFE_WRITE_FAILURES,
2499
+ unsafeWriteFailures,
2500
+ lowLevelWriteFailureLimit: MAX_LOW_LEVEL_WRITE_FAILURES,
2501
+ lowLevelWriteFailures,
2502
+ lowLevelWriteDisabled,
2503
+ lowLevelWriteBlockedCalls,
2504
+ instructionFallbacks,
2505
+ },
1087
2506
  branch: branchChange,
1088
2507
  lastInspectionAt,
2508
+ lastMutationAt,
2509
+ taskPlan: taskPlanSnapshot(),
2510
+ verification: verification(),
2511
+ framerAgentVersion,
2512
+ framerGuidanceLoaded: guidanceMetadata.loaded,
2513
+ doctrineLoaded: promptMetadata.doctrineLoaded,
2514
+ guidanceHash: guidanceMetadata.contentHash,
2515
+ projectContextHash: guidanceMetadata.projectContextHash,
2516
+ visualVerificationRequired:
2517
+ verification().visualRequired,
2518
+ visualVerificationCompleted:
2519
+ verification().visualCompleted,
2520
+ guidance: {
2521
+ loaded: guidanceMetadata.loaded,
2522
+ contentHash: guidanceMetadata.contentHash,
2523
+ projectContextHash: guidanceMetadata.projectContextHash,
2524
+ files: [...guidanceMetadata.files],
2525
+ bytes: guidanceMetadata.bytes,
2526
+ },
2527
+ doctrine: { ...promptMetadata },
1089
2528
  }),
1090
2529
  fatalInfrastructureError: () => null,
1091
2530
  waitForFatalInfrastructure: () => new Promise(() => undefined),