@pinepaper.studio/mcp-server 1.6.23 → 1.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +212 -17
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/SECURITY.md +46 -0
  11. package/dist/agent/types.d.ts +114 -106
  12. package/dist/agent/types.d.ts.map +1 -1
  13. package/dist/agent/types.js +1 -1
  14. package/dist/agent/types.js.map +1 -1
  15. package/dist/browser/puppeteer-controller.d.ts +6 -0
  16. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  17. package/dist/browser/puppeteer-controller.js +99 -71
  18. package/dist/browser/puppeteer-controller.js.map +1 -1
  19. package/dist/build-info.json +2 -2
  20. package/dist/cli.js +7 -5
  21. package/dist/cli.js.map +1 -1
  22. package/dist/design/design-systems.js +2 -2
  23. package/dist/design/design-systems.js.map +1 -1
  24. package/dist/i18n/locales/en.js +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +63 -12
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp/index.d.ts +5 -0
  30. package/dist/mcp/index.d.ts.map +1 -0
  31. package/dist/mcp/index.js +5 -0
  32. package/dist/mcp/index.js.map +1 -0
  33. package/dist/mcp/server.d.ts +86 -0
  34. package/dist/mcp/server.d.ts.map +1 -0
  35. package/dist/mcp/server.js +159 -0
  36. package/dist/mcp/server.js.map +1 -0
  37. package/dist/mcp/stdio.d.ts +20 -0
  38. package/dist/mcp/stdio.d.ts.map +1 -0
  39. package/dist/mcp/stdio.js +82 -0
  40. package/dist/mcp/stdio.js.map +1 -0
  41. package/dist/mcp/types.d.ts +598 -0
  42. package/dist/mcp/types.d.ts.map +1 -0
  43. package/dist/mcp/types.js +140 -0
  44. package/dist/mcp/types.js.map +1 -0
  45. package/dist/metrics/performance-tracker.d.ts.map +1 -1
  46. package/dist/metrics/performance-tracker.js +4 -3
  47. package/dist/metrics/performance-tracker.js.map +1 -1
  48. package/dist/ontology/hints.d.ts.map +1 -1
  49. package/dist/ontology/hints.js +8 -2
  50. package/dist/ontology/hints.js.map +1 -1
  51. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  52. package/dist/ontology/upstream-vocabulary.d.ts.map +1 -1
  53. package/dist/ontology/upstream-vocabulary.js +8 -2
  54. package/dist/ontology/upstream-vocabulary.js.map +1 -1
  55. package/dist/ontology/vocabulary.d.ts.map +1 -1
  56. package/dist/ontology/vocabulary.js +1 -0
  57. package/dist/ontology/vocabulary.js.map +1 -1
  58. package/dist/server/index.d.ts +1 -1
  59. package/dist/server/index.d.ts.map +1 -1
  60. package/dist/server/index.js +1 -1
  61. package/dist/server/index.js.map +1 -1
  62. package/dist/tools/batch-macros.d.ts +27 -0
  63. package/dist/tools/batch-macros.d.ts.map +1 -0
  64. package/dist/tools/batch-macros.js +158 -0
  65. package/dist/tools/batch-macros.js.map +1 -0
  66. package/dist/tools/choreograph.d.ts.map +1 -1
  67. package/dist/tools/choreograph.js +2 -0
  68. package/dist/tools/choreograph.js.map +1 -1
  69. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  70. package/dist/tools/compact-descriptions.js +8 -7
  71. package/dist/tools/compact-descriptions.js.map +1 -1
  72. package/dist/tools/craft-tools.d.ts +1 -1
  73. package/dist/tools/craft-tools.d.ts.map +1 -1
  74. package/dist/tools/create-known-keys.d.ts +2 -0
  75. package/dist/tools/create-known-keys.d.ts.map +1 -0
  76. package/dist/tools/create-known-keys.js +24 -0
  77. package/dist/tools/create-known-keys.js.map +1 -0
  78. package/dist/tools/definitions.d.ts +3 -1
  79. package/dist/tools/definitions.d.ts.map +1 -1
  80. package/dist/tools/definitions.js +122 -19
  81. package/dist/tools/definitions.js.map +1 -1
  82. package/dist/tools/effect-check.d.ts +1 -1
  83. package/dist/tools/effect-check.d.ts.map +1 -1
  84. package/dist/tools/effect-check.js +4 -0
  85. package/dist/tools/effect-check.js.map +1 -1
  86. package/dist/tools/engine-lists.d.ts +5 -2
  87. package/dist/tools/engine-lists.d.ts.map +1 -1
  88. package/dist/tools/engine-lists.js +10 -2
  89. package/dist/tools/engine-lists.js.map +1 -1
  90. package/dist/tools/engine-methods.d.ts.map +1 -1
  91. package/dist/tools/engine-methods.js +2 -0
  92. package/dist/tools/engine-methods.js.map +1 -1
  93. package/dist/tools/engine-surface.d.ts +1 -1
  94. package/dist/tools/engine-surface.d.ts.map +1 -1
  95. package/dist/tools/engine-surface.js +67 -6
  96. package/dist/tools/engine-surface.js.map +1 -1
  97. package/dist/tools/export-file-check.d.ts +31 -0
  98. package/dist/tools/export-file-check.d.ts.map +1 -0
  99. package/dist/tools/export-file-check.js +103 -0
  100. package/dist/tools/export-file-check.js.map +1 -0
  101. package/dist/tools/export-file-receipt.d.ts +68 -0
  102. package/dist/tools/export-file-receipt.d.ts.map +1 -0
  103. package/dist/tools/export-file-receipt.js +171 -0
  104. package/dist/tools/export-file-receipt.js.map +1 -0
  105. package/dist/tools/handlers/beats.d.ts +8 -45
  106. package/dist/tools/handlers/beats.d.ts.map +1 -1
  107. package/dist/tools/handlers/camera.d.ts +1 -1
  108. package/dist/tools/handlers/camera.d.ts.map +1 -1
  109. package/dist/tools/handlers/export.d.ts +1 -1
  110. package/dist/tools/handlers/export.d.ts.map +1 -1
  111. package/dist/tools/handlers/export.js +34 -8
  112. package/dist/tools/handlers/export.js.map +1 -1
  113. package/dist/tools/handlers/font.d.ts +1 -1
  114. package/dist/tools/handlers/font.d.ts.map +1 -1
  115. package/dist/tools/handlers/font.js +8 -7
  116. package/dist/tools/handlers/font.js.map +1 -1
  117. package/dist/tools/handlers/generate.d.ts +42 -271
  118. package/dist/tools/handlers/generate.d.ts.map +1 -1
  119. package/dist/tools/handlers/generate.js +4 -3
  120. package/dist/tools/handlers/generate.js.map +1 -1
  121. package/dist/tools/handlers/maps.d.ts +1 -1
  122. package/dist/tools/handlers/maps.d.ts.map +1 -1
  123. package/dist/tools/handlers/ontology.d.ts +1 -1
  124. package/dist/tools/handlers/ontology.d.ts.map +1 -1
  125. package/dist/tools/handlers/tool-guide.d.ts +1 -1
  126. package/dist/tools/handlers/tool-guide.d.ts.map +1 -1
  127. package/dist/tools/handlers.d.ts +82 -4
  128. package/dist/tools/handlers.d.ts.map +1 -1
  129. package/dist/tools/handlers.js +366 -89
  130. package/dist/tools/handlers.js.map +1 -1
  131. package/dist/tools/lane.d.ts +39 -0
  132. package/dist/tools/lane.d.ts.map +1 -0
  133. package/dist/tools/lane.js +247 -0
  134. package/dist/tools/lane.js.map +1 -0
  135. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  136. package/dist/tools/minimal-descriptions.js +3 -2
  137. package/dist/tools/minimal-descriptions.js.map +1 -1
  138. package/dist/tools/nested-ignored.d.ts +16 -0
  139. package/dist/tools/nested-ignored.d.ts.map +1 -0
  140. package/dist/tools/nested-ignored.js +83 -0
  141. package/dist/tools/nested-ignored.js.map +1 -0
  142. package/dist/tools/p5-compat/p5-helpers.d.ts +1 -1
  143. package/dist/tools/p5-compat/p5-helpers.js +1 -1
  144. package/dist/tools/shape-params.d.ts +2 -2
  145. package/dist/tools/shape-params.d.ts.map +1 -1
  146. package/dist/tools/shape-params.js +15 -2
  147. package/dist/tools/shape-params.js.map +1 -1
  148. package/dist/tools/stick-vocabulary.d.ts +4 -4
  149. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  150. package/dist/tools/stick-vocabulary.js +8 -4
  151. package/dist/tools/stick-vocabulary.js.map +1 -1
  152. package/dist/tools/timeline.d.ts +136 -0
  153. package/dist/tools/timeline.d.ts.map +1 -0
  154. package/dist/tools/timeline.js +1041 -0
  155. package/dist/tools/timeline.js.map +1 -0
  156. package/dist/tools/toolkits.d.ts +22 -7
  157. package/dist/tools/toolkits.d.ts.map +1 -1
  158. package/dist/tools/toolkits.js +113 -21
  159. package/dist/tools/toolkits.js.map +1 -1
  160. package/dist/types/code-generator.d.ts +55 -2
  161. package/dist/types/code-generator.d.ts.map +1 -1
  162. package/dist/types/code-generator.js +671 -202
  163. package/dist/types/code-generator.js.map +1 -1
  164. package/dist/types/schemas.d.ts +5281 -25048
  165. package/dist/types/schemas.d.ts.map +1 -1
  166. package/dist/types/schemas.js +81 -41
  167. package/dist/types/schemas.js.map +1 -1
  168. package/dist/utils/asset-paths.d.ts.map +1 -1
  169. package/dist/utils/asset-paths.js +4 -4
  170. package/dist/utils/asset-paths.js.map +1 -1
  171. package/dist/utils/env.d.ts +9 -0
  172. package/dist/utils/env.d.ts.map +1 -0
  173. package/dist/utils/env.js +11 -0
  174. package/dist/utils/env.js.map +1 -0
  175. package/dist/utils/export-dir.d.ts +36 -0
  176. package/dist/utils/export-dir.d.ts.map +1 -0
  177. package/dist/utils/export-dir.js +86 -0
  178. package/dist/utils/export-dir.js.map +1 -0
  179. package/dist/utils/js-literal.d.ts +4 -0
  180. package/dist/utils/js-literal.d.ts.map +1 -0
  181. package/dist/utils/js-literal.js +25 -0
  182. package/dist/utils/js-literal.js.map +1 -0
  183. package/dist/utils/mcp-log.d.ts +1 -1
  184. package/dist/utils/mcp-log.d.ts.map +1 -1
  185. package/dist/version.d.ts.map +1 -1
  186. package/dist/version.js +5 -12
  187. package/dist/version.js.map +1 -1
  188. package/manifest.json +46 -63
  189. package/package.json +15 -12
@@ -5,13 +5,15 @@
5
5
  * Supports i18n for localized error and success messages.
6
6
  * Supports browser execution via Puppeteer.
7
7
  */
8
+ import { nestedIgnoredPaths } from './nested-ignored.js';
8
9
  import { CRAFT_FRAME_JS, generateDrawWeave, generateDrawKnots, generateDrawSashiko, generateDrawSubdivision, generateUiKit, uiKitCallProblems, } from './craft-tools.js';
9
10
  import { codeGenerator, generateQueryMutationsCode, SHEET_RUBRIC } from '../types/code-generator.js';
11
+ import { expandBatchMacros, BatchMacroError } from './batch-macros.js';
10
12
  import { CreateItemInputSchema, ModifyItemInputSchema, DeleteItemInputSchema, AddRelationInputSchema, RemoveRelationInputSchema, QueryRelationsInputSchema, AnimateItemInputSchema, ImportMotionCaptureInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, GetItemsInputSchema, PlayTimelineInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, ImportSVGInputSchema, ImportMermaidInputSchema, AddFilterInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, GeometryInputSchema, EquationPathInputSchema, EventInputSchema, ConstructionSequenceInputSchema, ValidateSceneInputSchema, CaptureFramesInputSchema, InstantiateOntologyInputSchema, LintSceneInputSchema, MediaInputSchema, TextStyleInputSchema, TextEffectInputSchema, DesignMediumInputSchema, RelightInputSchema, ChoreographInputSchema, EmitterInputSchema, MorphSequenceInputSchema, OriginalCharacterInputSchema, LookInputSchema, RenderHookInputSchema, ShaderGraphInputSchema, ShatterImageInputSchema, ImportLayeredCharacterInputSchema, CharacterInputSchema, GameInputSchema, World3DInputSchema, BrandKitInputSchema, ComponentInputSchema, ArtboardInputSchema, CommentInputSchema, ProvenanceInputSchema, SceneDiffInputSchema, AudioBeatsInputSchema, TemplateParamsInputSchema, RenderBatchInputSchema, AccessibilityCheckInputSchema, QueryMutationsInputSchema, ComposeInputSchema, CropImageInputSchema, PathOpInputSchema, MotionInputSchema, SoundInputSchema, InterchangeInputSchema, StickInputSchema, DesignSystemInputSchema, StoryInputSchema, ChromaKeyInputSchema, RiggingInputSchema, GroupInputSchema, CameraDirectorInputSchema, DetectObjectsInputSchema, ExtractObjectInputSchema, ArrangeInputSchema, CreateSceneInputSchema, GetPerformanceMetricsInputSchema, DiagnosticReportInputSchema, SearchAssetsInputSchema, ImportAssetInputSchema, P5DrawInputSchema,
11
13
  // Diagram schemas
12
14
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
13
15
  // Agent flow mode schemas
14
- AgentStartJobInputSchema, AgentEndJobInputSchema, AgentResetInputSchema, AgentBatchExecuteInputSchema, AgentExportInputSchema, ExportStoreInputSchema, AgentAnalyzeInputSchema,
16
+ AgentStartJobInputSchema, AgentEndJobInputSchema, AgentResetInputSchema, AgentBatchExecuteInputSchema, AgentBatchOperationSchema, AgentExportInputSchema, ExportStoreInputSchema, AgentAnalyzeInputSchema,
15
17
  // Letter collage schemas
16
18
  CreateLetterCollageInputSchema, AnimateLetterCollageInputSchema,
17
19
  // Map schemas
@@ -26,15 +28,16 @@ ImportImageInputSchema,
26
28
  // Tool guide schema
27
29
  // Ontology schemas
28
30
  // Scene management schemas
29
- ManageScenesInputSchema, ScenePlaybackInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema,
31
+ ManageScenesInputSchema, ScenePlaybackInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, PatternInputSchema, FlipInputSchema,
30
32
  // New consolidated tool schemas
31
33
  SelectionInputSchema, TransformInputSchema, HistoryInputSchema, ImageFilterInputSchema, LassoInputSchema, CutoutStyleInputSchema, PrecompInputSchema, BackgroundInputSchema, QueryInputSchema, QueryCapabilitiesInputSchema, DeformInputSchema, HandDrawnInputSchema, PenFigureInputSchema, CreateCharacterInputSchema, CharacterActionInputSchema, CharacterPoseInputSchema, SpriteSheetInputSchema, InteractionInputSchema, ExportWidgetInputSchema, ExportWidgetHtmlInputSchema, CreateChartInputSchema, MagicInputSchema, PhysicsInputSchema, MeasurementInputSchema, PlaceOnSurfaceInputSchema, DrawWeaveInputSchema, DrawKnotsInputSchema, DrawSashikoInputSchema, DrawSubdivisionInputSchema, UiKitInputSchema, StyledSceneInputSchema, ErrorCodes, } from '../types/schemas.js';
32
34
  import { ZodError, z } from 'zod';
33
35
  import { AsyncLocalStorage } from 'node:async_hooks';
34
36
  import { effectClass } from './effect-class.js';
37
+ import { exportFileReceiptOrReason, receiptErrorLine } from './export-file-receipt.js';
38
+ import { ExportStepError, errorText, verifySavedFile, writeStep, bytesForSave } from './export-file-check.js';
35
39
  import { EFFECT_BEFORE_JS, effectAfterJs, namedItemIds, idMayBeGone, NO_CHANGE_NOTE } from './effect-check.js';
36
40
  import { writeFile, mkdir, appendFile, unlink, readFile, stat } from 'node:fs/promises';
37
- import { tmpdir } from 'node:os';
38
41
  import { join, extname } from 'node:path';
39
42
  import { fileURLToPath } from 'node:url';
40
43
  import { getBrowserController, resetBrowserController, } from '../browser/puppeteer-controller.js';
@@ -44,6 +47,7 @@ import { formatErrorContext, captureCanvasState } from '../execution/index.js';
44
47
  import { FILTER_PARAM_RANGES } from './engine-lists.js';
45
48
  import { getSessionManager } from '../agent/session-manager.js';
46
49
  import { vocabularyHintForPath, validateBatchVocabulary, detectBatchPropertyTypos } from '../ontology/hints.js';
50
+ import { compileBatchTimelines } from './timeline.js';
47
51
  import { cameraHandlers } from './handlers/camera.js';
48
52
  import { fontHandlers } from './handlers/font.js';
49
53
  import { toolGuideHandlers } from './handlers/tool-guide.js';
@@ -54,12 +58,15 @@ import { exportHandlers } from './handlers/export.js';
54
58
  import { generateHandlers } from './handlers/generate.js';
55
59
  import { beatHandlers } from './handlers/beats.js';
56
60
  import { planCharacter, generateCharacterCode } from './handlers/character.js';
61
+ import { jsLiteral } from '../utils/js-literal.js';
57
62
  import { buildZip } from '../utils/zip.js';
58
63
  import { buildScc } from '../utils/scc.js';
59
64
  import { checkContrast, checkFlashes, parseCssColor } from '../utils/a11y.js';
60
65
  import { buildHtml5Ad, buildPlayable, externalRequests, pinSceneBackground } from '../utils/ad-package.js';
61
66
  import { safeFetch, checkUrl, UnsafeUrlError } from '../utils/safe-fetch.js';
62
67
  import { confineAssetPath } from '../utils/asset-paths.js';
68
+ import { env } from '../utils/env.js';
69
+ import { getExportDir, exportFileLink } from '../utils/export-dir.js';
63
70
  /**
64
71
  * Registry of per-domain handler maps. Tools listed here short-circuit the
65
72
  * main switch at dispatch time (see handleToolCallInner). New domains are
@@ -82,7 +89,7 @@ function getDomainHandlers() {
82
89
  * Defaults to 'on_request' for optimal performance.
83
90
  */
84
91
  export function getScreenshotMode() {
85
- const mode = process.env.PINEPAPER_SCREENSHOT_MODE?.toLowerCase();
92
+ const mode = env('PINEPAPER_SCREENSHOT_MODE')?.toLowerCase();
86
93
  if (mode === 'always' || mode === 'never' || mode === 'on_request') {
87
94
  return mode;
88
95
  }
@@ -92,9 +99,8 @@ export function getScreenshotMode() {
92
99
  // =============================================================================
93
100
  // EXPORT FILE SAVE HELPERS
94
101
  // =============================================================================
95
- export function getExportDir() {
96
- return process.env.PINEPAPER_EXPORT_DIR || join(tmpdir(), 'pinepaper-exports');
97
- }
102
+ // The export folder, one definition for the writers and for path confinement (utils/export-dir.ts).
103
+ export { getExportDir };
98
104
  // wav joins them: a minute of 48kHz 16-bit is ~5.8 MB of base64 and the ten
99
105
  // minutes the schema allows is ~77 MB. Deliverable across the bridge, useless
100
106
  // pasted into a response.
@@ -130,10 +136,10 @@ const SAVE_THRESHOLD_BYTES = 180_000; // ~180KB base64 ≈ 135KB decoded
130
136
  */
131
137
  const EXPORT_GOVERNOR_DEFAULT_MS = 300_000;
132
138
  function exportGovernorTimeoutMs() {
133
- const raw = Number(process.env.PINEPAPER_EXPORT_TIMEOUT);
139
+ const raw = Number(env('PINEPAPER_EXPORT_TIMEOUT'));
134
140
  if (!Number.isFinite(raw) || raw <= 0)
135
141
  return EXPORT_GOVERNOR_DEFAULT_MS;
136
- const protocolFloor = Math.max(Number(process.env.PINEPAPER_TIMEOUT) || 0, EXPORT_GOVERNOR_DEFAULT_MS);
142
+ const protocolFloor = Math.max(Number(env('PINEPAPER_TIMEOUT')) || 0, EXPORT_GOVERNOR_DEFAULT_MS);
137
143
  if (raw > protocolFloor) {
138
144
  console.error(`[PinePaper] PINEPAPER_EXPORT_TIMEOUT=${raw}ms is above the browser's protocol timeout (${protocolFloor}ms) — ` +
139
145
  'raise PINEPAPER_TIMEOUT to match, or an export past that point fails as a protocol error rather than a governor timeout.');
@@ -372,12 +378,15 @@ async function saveAdExport(r, input, platform) {
372
378
  const html = buildHtml5Ad(r.adPage, opts);
373
379
  const zip = buildZip([{ name: 'index.html', data: html }]);
374
380
  files.zip = join(exportDir, `${stamp}.zip`);
375
- await writeFile(files.zip, zip);
381
+ await writeStep(files.zip, () => writeFile(files.zip, zip));
382
+ await verifySavedFile(files.zip, zip.length);
376
383
  uploadBytes = zip.length;
377
384
  // The backup image is uploaded BESIDE the zip, not inside it.
378
385
  if (r.backupImage && r.backupImage.startsWith('data:')) {
379
386
  files.backupImage = join(exportDir, `${stamp}_backup.png`);
380
- await writeFile(files.backupImage, Buffer.from(r.backupImage.split(',')[1], 'base64'));
387
+ const backup = bytesForSave(r.backupImage, 'the backup image');
388
+ await writeStep(files.backupImage, () => writeFile(files.backupImage, backup));
389
+ await verifySavedFile(files.backupImage, backup.length);
381
390
  }
382
391
  else {
383
392
  warnings.push({ code: 'no_backup_image', message: 'no backup image was made (the studio could not capture a frame); display networks require one — export format "png" at the same size.' });
@@ -386,8 +395,9 @@ async function saveAdExport(r, input, platform) {
386
395
  else {
387
396
  const html = buildPlayable(r.adPage, opts);
388
397
  files.html = join(exportDir, `${stamp}.html`);
389
- await writeFile(files.html, html, 'utf-8');
398
+ await writeStep(files.html, () => writeFile(files.html, html, 'utf-8'));
390
399
  uploadBytes = Buffer.byteLength(html, 'utf-8');
400
+ await verifySavedFile(files.html, uploadBytes);
391
401
  }
392
402
  // 150 KB is Google Ads' display HTML5 figure; some uploads allow 600 KB, and
393
403
  // playable networks set their own — so it is a default, not a rule.
@@ -411,27 +421,31 @@ async function saveAdExport(r, input, platform) {
411
421
  content: [{
412
422
  type: 'text',
413
423
  text: `Ad saved:\n\n${Object.entries(files).map(([k, v]) => `${k}: ${v}`).join('\n')}\nFormat: ${r.format}\nSize: ${r.adSize.width}x${r.adSize.height}, upload ${(uploadBytes / 1024).toFixed(1)} KB\n\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
414
- }],
424
+ }, ...Object.values(files).filter((f) => typeof f === 'string').map((f) => exportFileLink(f))],
415
425
  };
416
426
  }
417
427
  async function saveExportToFile(data, format, platform) {
418
428
  const exportDir = getExportDir();
419
- await mkdir(exportDir, { recursive: true });
429
+ await writeStep(exportDir, () => mkdir(exportDir, { recursive: true }));
420
430
  const ext = getFileExtension(format);
421
431
  const timestamp = Date.now();
422
432
  const fileName = `pinepaper_${platform}_${timestamp}.${ext}`;
423
433
  const filePath = join(exportDir, fileName);
424
- if (data.startsWith('data:')) {
425
- const base64Data = data.split(',')[1];
426
- const buffer = Buffer.from(base64Data, 'base64');
427
- await writeFile(filePath, buffer);
428
- return { filePath, fileSize: buffer.length };
434
+ // A data URL is decoded (base64 or percent-encoded); plain text (SVG, captions) is written as UTF-8. Nothing to
435
+ // write is the `export` or `save` step's failure, named — it used to write a 0-byte file and report it saved.
436
+ let buffer;
437
+ try {
438
+ buffer = bytesForSave(data, `the ${format} export`);
429
439
  }
430
- else {
431
- // Plain text (SVG)
432
- await writeFile(filePath, data, 'utf-8');
433
- return { filePath, fileSize: Buffer.byteLength(data, 'utf-8') };
440
+ catch (e) {
441
+ if (e instanceof ExportStepError)
442
+ throw new ExportStepError(e.step, filePath, e.reason);
443
+ throw e;
434
444
  }
445
+ await writeStep(filePath, () => writeFile(filePath, buffer));
446
+ // Read back: success is a file that exists and holds what was written.
447
+ await verifySavedFile(filePath, buffer.length);
448
+ return { filePath, fileSize: buffer.length };
435
449
  }
436
450
  /**
437
451
  * Page a RETAINED export out of the studio's export store into a local file.
@@ -459,15 +473,16 @@ async function streamRetainedExportToFile(controller, retained, platform) {
459
473
  let offset = 0;
460
474
  let chunks = 0;
461
475
  // Start from empty: appendFile would otherwise extend a same-millisecond file.
462
- await writeFile(filePath, Buffer.alloc(0));
476
+ await writeStep(filePath, () => writeFile(filePath, Buffer.alloc(0)));
463
477
  // Returns the error rather than throwing it, so `throw await abandon(...)`
464
478
  // reads as a throw to the type checker and narrows what follows.
465
- const abandon = async (why) => {
479
+ // Paging the bytes out is the `save` step; a refusal from the file system is the `write` step.
480
+ const abandon = async (why, step = 'save') => {
466
481
  try {
467
482
  await unlink(filePath);
468
483
  }
469
484
  catch { /* nothing written yet */ }
470
- return new Error(
485
+ return new ExportStepError(step, filePath,
471
486
  // The promise now names the call that keeps it. It was true before and
472
487
  // unreachable: reading a held export mutates nothing, so "it can be paged
473
488
  // again" was always correct and there was no door to page it through.
@@ -546,7 +561,12 @@ async function streamRetainedExportToFile(controller, retained, platform) {
546
561
  const buf = Buffer.from(chunk.data || '', 'base64');
547
562
  if (buf.length === 0 && chunk.eof !== true)
548
563
  throw await abandon('the studio returned an empty chunk before the end of the file');
549
- await appendFile(filePath, buf);
564
+ try {
565
+ await appendFile(filePath, buf);
566
+ }
567
+ catch (writeError) {
568
+ throw await abandon(`appending ${buf.length} bytes at offset ${offset} failed: ${errorText(writeError, 'the file system gave no reason')}`, 'write');
569
+ }
550
570
  offset += buf.length;
551
571
  chunks++;
552
572
  if (chunk.eof === true)
@@ -557,6 +577,16 @@ async function streamRetainedExportToFile(controller, retained, platform) {
557
577
  if (offset !== retained.size) {
558
578
  throw await abandon(`reassembled ${offset} bytes but the studio reported ${retained.size}`);
559
579
  }
580
+ // An empty export is not a saved file, and neither is one the disk does not hold: read it back BEFORE the
581
+ // studio's copy is released, so a failure here still leaves the bytes recoverable.
582
+ if (offset === 0)
583
+ throw await abandon('the studio held an empty export (0 bytes): there is nothing to save');
584
+ try {
585
+ await verifySavedFile(filePath, offset);
586
+ }
587
+ catch (verifyError) {
588
+ throw await abandon(errorText(verifyError instanceof ExportStepError ? verifyError.reason : verifyError, 'the written file could not be read back'), 'write');
589
+ }
560
590
  // Only now is it safe to drop the studio's copy.
561
591
  try {
562
592
  await controller.executeCode(codeGenerator.generateReleaseExport(retained.exportId), false);
@@ -572,7 +602,7 @@ async function streamRetainedExportToFile(controller, retained, platform) {
572
602
  * Defaults to 'puppeteer' for live execution.
573
603
  */
574
604
  export function getExecutionMode() {
575
- const mode = process.env.PINEPAPER_EXECUTION_MODE?.toLowerCase();
605
+ const mode = env('PINEPAPER_EXECUTION_MODE')?.toLowerCase();
576
606
  if (mode === 'puppeteer' || mode === 'code') {
577
607
  return mode;
578
608
  }
@@ -685,10 +715,40 @@ function withSessionNote(out, r) {
685
715
  }
686
716
  return out;
687
717
  }
718
+ export const RECEIPTS_META_KEY = 'pinepaper.studio/receipts';
719
+ /** The engine run's receipts (report.receipts: { calls, byDoor, notes }), when any call has something to say. */
720
+ export function engineReceipts(report) {
721
+ const r = report && typeof report === 'object' ? report.receipts : undefined;
722
+ if (!r || !Array.isArray(r.notes) || r.notes.length === 0)
723
+ return null;
724
+ return { calls: Number(r.calls) || 0, byDoor: r.byDoor ?? {}, notes: r.notes };
725
+ }
726
+ /**
727
+ * agent_export answered from the export's own result and never read the run's report (AR-1 R2): an export's engine
728
+ * warnings and receipts are now on the reply like every other tool's — a text line an agent reads, and _meta.
729
+ */
730
+ export function withEngineReport(result, report) {
731
+ if (!report || result.isError)
732
+ return result;
733
+ const receipts = engineReceipts(report);
734
+ const engineWarnings = Array.isArray(report.engineWarnings) ? report.engineWarnings.filter((w) => w && typeof w === 'object') : [];
735
+ if (!receipts && !engineWarnings.length)
736
+ return result;
737
+ const body = { ...(receipts ? { receipts } : {}), ...(engineWarnings.length ? { engineWarnings } : {}) };
738
+ return {
739
+ ...result,
740
+ content: [...(result.content ?? []), { type: 'text', text: `ENGINE RECEIPTS: ${JSON.stringify(body)}` }],
741
+ _meta: { ...(result._meta ?? {}), [RECEIPTS_META_KEY]: body },
742
+ };
743
+ }
688
744
  function executedResult(code, result, screenshot, description, report) {
689
745
  const resultObj = { success: true, result };
690
746
  if (description)
691
747
  resultObj.description = description;
748
+ // The scene lint after the code ran, when there is something to say.
749
+ const lint = report && Array.isArray(report.lintWarnings) ? report.lintWarnings : [];
750
+ if (lint.length)
751
+ resultObj.lintWarnings = lint;
692
752
  // Surface the governor's model-actionable feedback (app.runGenerated report):
693
753
  // warnings (e.g. "500 items created one-by-one — prefer a pattern"), item count,
694
754
  // and guard trips. Omitted entirely on pre-governor FxTool builds (no report).
@@ -711,6 +771,11 @@ function executedResult(code, result, screenshot, description, report) {
711
771
  governor.loopGuardTripped = true;
712
772
  if (Object.keys(governor).length)
713
773
  resultObj.governor = governor;
774
+ // AR-1 R2: what each engine call did with what it was given (FxTool js/core/Receipt.js) — only when a call
775
+ // renamed, fell back, dropped or warned; a run whose calls all applied as asked adds nothing.
776
+ const receipts = engineReceipts(report);
777
+ if (receipts)
778
+ resultObj.receipts = receipts;
714
779
  }
715
780
  // THE CODE ECHO WAS 90% OF THE RESPONSE, AND THE AGENT WROTE THE REQUEST.
716
781
  //
@@ -728,7 +793,7 @@ function executedResult(code, result, screenshot, description, report) {
728
793
  const content = [
729
794
  { type: 'text', text: JSON.stringify(resultObj, null, 2) },
730
795
  ];
731
- if (process.env.PINEPAPER_ECHO_CODE === '1') {
796
+ if (env('PINEPAPER_ECHO_CODE') === '1') {
732
797
  content.push({ type: 'text', text: `Generated code:\n\`\`\`javascript\n${code}\n\`\`\`` });
733
798
  }
734
799
  if (screenshot) {
@@ -758,7 +823,7 @@ function screenshotResult(screenshot) {
758
823
  * error code, not a program, and stripping those would take information away.
759
824
  */
760
825
  function redactGeneratedCode(details) {
761
- if (process.env.PINEPAPER_ECHO_CODE === '1')
826
+ if (env('PINEPAPER_ECHO_CODE') === '1')
762
827
  return details;
763
828
  if (!details || typeof details !== 'object' || Array.isArray(details))
764
829
  return details;
@@ -777,6 +842,44 @@ function redactGeneratedCode(details) {
777
842
  },
778
843
  };
779
844
  }
845
+ /**
846
+ * The studio's scene-lint warnings on an export (FxTool lintScene), one line each, so a centred title that runs
847
+ * out of the frame ("IGNAL" for "SIGNAL") is read on the success path rather than left in the JSON. Empty when none.
848
+ */
849
+ export function lintLines(exportResult) {
850
+ const w = exportResult?.lintWarnings;
851
+ if (!Array.isArray(w) || !w.length)
852
+ return '';
853
+ return w.map((x) => `LINT: ${String(x?.message ?? x?.code ?? 'scene lint warning')}\n`).join('');
854
+ }
855
+ /**
856
+ * The file as ffprobe read it, when it disagrees with what the studio encoded: one line per field. A summary
857
+ * line when they agree; nothing when there is no summary.
858
+ */
859
+ export function probeLines(receipt) {
860
+ const p = receipt.probe;
861
+ if (!p)
862
+ return '';
863
+ const head = `Probe: ${p.container} ${p.codec} ${p.width}x${p.height}${p.fps ? ` @ ${p.fps} fps` : ''}, ${p.durationS} s, ${p.frames} frame${p.frames === 1 ? '' : 's'}`;
864
+ const c = receipt.probeCheck;
865
+ if (!c)
866
+ return `${head}\n`;
867
+ if (c.tool === null)
868
+ return `${head} (not read back: ${c.reason})\n`;
869
+ if (c.ok)
870
+ return `${head} (ffprobe agrees)\n`;
871
+ if (!c.mismatches?.length)
872
+ return `${head}\nPROBE: ${c.reason ?? 'ffprobe could not confirm the file'}\n`;
873
+ return `${head}\n` + c.mismatches.map((m) => `PROBE MISMATCH: ${m.field} — the studio encoded ${JSON.stringify(m.studio)}, ffprobe reads ${JSON.stringify(m.file)}\n`).join('');
874
+ }
875
+ /**
876
+ * An export that failed at a step, as an error result: `{ step, path, reason }` in the data and in the words.
877
+ * A non-ExportStepError is named at `fallbackStep` with its own message, never "undefined".
878
+ */
879
+ function exportStepErrorResult(e, fallbackStep, extra = {}, toolName = 'pinepaper_agent_export') {
880
+ const err = e instanceof ExportStepError ? e : new ExportStepError(fallbackStep, null, errorText(e, `the ${fallbackStep} step failed with no reason given`));
881
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, { ...err.toData(), ...extra }, { toolName });
882
+ }
780
883
  export function errorResult(code, message, rawDetails, context) {
781
884
  // THE GENERATED SCRIPT IS NOT THE ERROR.
782
885
  //
@@ -811,15 +914,19 @@ export function errorResult(code, message, rawDetails, context) {
811
914
  isError: true,
812
915
  };
813
916
  }
814
- function handleValidationError(error, i18n) {
917
+ function handleValidationError(error, i18n, args) {
815
918
  const message = i18n
816
919
  ? i18n.getError('validationError', { message: 'Invalid input parameters' })
817
920
  : 'Invalid input parameters';
818
- const details = error.errors.map((e) => {
921
+ const details = error.issues.map((e) => {
819
922
  const path = e.path.join('.');
820
923
  const detail = { path, message: e.message };
821
- if (e.code === 'invalid_enum_value') {
822
- const received = e.received;
924
+ // zod 4 names a value outside an enum `invalid_value` and no longer carries what was sent; it is read back from the
925
+ // call's own arguments along the issue's path, so the vocabulary hint ("did you mean …") still has a word to match.
926
+ if (e.code === 'invalid_value') {
927
+ let received = args;
928
+ for (const k of e.path)
929
+ received = received && typeof received === 'object' ? received[k] : undefined;
823
930
  const hint = vocabularyHintForPath(path, received);
824
931
  if (hint)
825
932
  detail.vocabulary = hint;
@@ -979,9 +1086,7 @@ export async function customCodeRollback(controller, error) {
979
1086
  removed = v.removed ?? 0;
980
1087
  left = typeof v.left === 'number' ? v.left : -1;
981
1088
  }
982
- catch {
983
- rolledBack = false;
984
- }
1089
+ catch { /* rolledBack stays false: nothing was confirmed removed */ }
985
1090
  const scene = rolledBack
986
1091
  ? (removed > 0
987
1092
  ? `The ${removed} item(s) this call created before it stopped were removed. Changes it made to items that already existed are not reverted.`
@@ -1062,23 +1167,34 @@ async function writeResultDataToFile(out, ext) {
1062
1167
  return out;
1063
1168
  }
1064
1169
  const r = obj.result;
1065
- if (!r || r.data === undefined || r.data === null)
1170
+ if (!r || r.success === false)
1066
1171
  return out;
1067
- let bytes;
1068
- if (typeof r.data === 'string' && r.data.startsWith('data:'))
1069
- bytes = Buffer.from(r.data.slice(r.data.indexOf(',') + 1), 'base64');
1070
- else if (typeof r.data === 'string')
1071
- bytes = Buffer.from(r.data, 'utf-8');
1072
- else
1073
- bytes = Buffer.from(JSON.stringify(r.data), 'utf-8');
1172
+ if (r.data === undefined || r.data === null) {
1173
+ // A result that says in fields that it wrote no file (png-sequence frames left in the page: `file: null` and
1174
+ // `why`) claims no file: it passes as it is. Any other success with nothing to write is the exit-0-without-file
1175
+ // case, named.
1176
+ if (r.file === null && typeof r.why === 'string' && r.why.trim())
1177
+ return out;
1178
+ const err = new ExportStepError('export', null, `the ${ext} export reported success but returned no file data`);
1179
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, { ...err.toData(), result: r });
1180
+ }
1074
1181
  const dir = getExportDir();
1075
- await mkdir(dir, { recursive: true });
1076
1182
  const filePath = join(dir, `pinepaper_${String(r.format ?? 'export').replace(/[^a-z0-9-]/gi, '')}_${Date.now()}.${ext}`);
1077
- await writeFile(filePath, bytes);
1078
- const { data: _d, ...rest } = r;
1079
- void _d;
1080
- obj.result = { ...rest, filePath, bytes: bytes.length };
1081
- return { ...out, content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }, ...out.content.slice(1)] };
1183
+ // No success without a file that exists and is not empty: each failure names its step, path and reason.
1184
+ try {
1185
+ const bytes = typeof r.data === 'string' ? bytesForSave(r.data, `the ${ext} export`) : Buffer.from(JSON.stringify(r.data), 'utf-8');
1186
+ await writeStep(filePath, async () => { await mkdir(dir, { recursive: true }); await writeFile(filePath, bytes); });
1187
+ await verifySavedFile(filePath, bytes.length);
1188
+ const { data: _d, ...rest } = r;
1189
+ void _d;
1190
+ obj.result = { ...rest, filePath, bytes: bytes.length };
1191
+ return { ...out, content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }, ...out.content.slice(1), exportFileLink(filePath, bytes.length)] };
1192
+ }
1193
+ catch (e) {
1194
+ const err = e instanceof ExportStepError ? new ExportStepError(e.step, e.path ?? filePath, e.reason)
1195
+ : new ExportStepError('write', filePath, errorText(e, 'the file could not be written'));
1196
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, err.toData());
1197
+ }
1082
1198
  }
1083
1199
  /**
1084
1200
  * pinepaper_capture_frames with `sheet`: one tiled image of the film, saved
@@ -1282,8 +1398,9 @@ ${code}
1282
1398
  tracker.startTimer(`${timerId}_screenshot`);
1283
1399
  }
1284
1400
  // Agent-written code is rolled back when it fails part-way; a tool's own
1285
- // emitter is not (its verdict already describes what it left behind).
1286
- const rollbackOnFailure = toolName === 'pinepaper_execute_custom_code'
1401
+ // emitter is not (its verdict already describes what it left behind), except
1402
+ // an atomic batch, whose contract is that a refused call changed nothing.
1403
+ const rollbackOnFailure = (toolName === 'pinepaper_execute_custom_code' || options.rollbackCreatedOnFailure === true)
1287
1404
  && await markItemsBeforeRun(controller);
1288
1405
  const governorTimeoutMs = LONG_GOVERNOR_BUDGET_MS[toolName];
1289
1406
  // NO SILENT SUCCESS (effect-class.ts / effect-check.ts): fingerprint a
@@ -1308,6 +1425,10 @@ ${code}
1308
1425
  ...(governorTimeoutMs ? { governorTimeoutMs } : {}),
1309
1426
  });
1310
1427
  const browserDuration = tracker.endTimer(`${timerId}_browser_execution`);
1428
+ // THE SCENE LINT RIDES OUT OF BAND: an emitter that ran app.lintScene() returns it under __ppLint, and
1429
+ // execute_custom_code returns its own value under __ppValue. Both are unwrapped HERE, before any verdict reads the
1430
+ // result, so the code's own value is what is judged and returned, and the lint sits beside it.
1431
+ const lintWarnings = unwrapLint(result);
1311
1432
  // THE METRIC RECORDS THE CALL'S VERDICT, NOT THE TRANSPORT'S (gate H2,
1312
1433
  // 1.6.19). result.success only says the code RAN; an emitter that ran and
1313
1434
  // returned { success: false } is a failed call, and get_performance_metrics
@@ -1352,6 +1473,8 @@ ${code}
1352
1473
  // PP_LOOP_BUDGET, PP_TIMEOUT) so the model can correct on the next turn.
1353
1474
  {
1354
1475
  code, errorCode: result.errorCode, governorReport: result.report,
1476
+ failures: stepFailures(toolName, result.result, result.error || 'Failed to execute code in browser'),
1477
+ ...(lintWarnings.length ? { lintWarnings } : {}),
1355
1478
  ...(rollbackOnFailure ? await customCodeRollback(controller, result.error) : {}),
1356
1479
  }, {
1357
1480
  toolName,
@@ -1383,6 +1506,8 @@ ${code}
1383
1506
  .slice(0, 3);
1384
1507
  return withTiming(errorResult(ErrorCodes.EXECUTION_ERROR, said.length ? `${inner} The studio said: ${said.join(' | ')}` : inner, {
1385
1508
  code, result: result.result, governorReport: result.report,
1509
+ failures: stepFailures(toolName, result.result, inner),
1510
+ ...(lintWarnings.length ? { lintWarnings } : {}),
1386
1511
  ...(rollbackOnFailure ? await customCodeRollback(controller, inner) : {}),
1387
1512
  }, { toolName, canvasState: canvasState || undefined }), timing);
1388
1513
  }
@@ -1434,6 +1559,46 @@ ${code}
1434
1559
  * string with no `success: true` beside it is a failure too — it is what the
1435
1560
  * emitters' catch blocks return.
1436
1561
  */
1562
+ /**
1563
+ * Move an emitter's out-of-band scene lint (`__ppLint`) off its result, and unwrap execute_custom_code's own value
1564
+ * (`__ppValue`). The lint is also put on `run.report.lintWarnings`, where executedResult reads it. Returns the lint.
1565
+ */
1566
+ export function unwrapLint(run) {
1567
+ const r = run.result;
1568
+ if (!r || typeof r !== 'object' || Array.isArray(r) || !('__ppLint' in r))
1569
+ return [];
1570
+ const lint = Array.isArray(r.__ppLint) ? r.__ppLint : [];
1571
+ if ('__ppValue' in r || r.__ppWrapped === true)
1572
+ run.result = r.__ppValue;
1573
+ else {
1574
+ const { __ppLint: _l, ...rest } = r;
1575
+ void _l;
1576
+ run.result = rest;
1577
+ }
1578
+ if (lint.length)
1579
+ run.report = { ...(run.report ?? {}), lintWarnings: lint };
1580
+ return lint;
1581
+ }
1582
+ /**
1583
+ * Every failed step of a call as `{ step, itemId, reason }`, never a bare message: a batch's own `failures`, a
1584
+ * result that names its step, or the call itself with the item it named.
1585
+ */
1586
+ export function stepFailures(toolName, value, message) {
1587
+ const v = value;
1588
+ const reasonOf = (x) => (typeof x === 'string' && x.trim() ? x : '');
1589
+ if (v && typeof v === 'object' && Array.isArray(v.failures) && v.failures.length) {
1590
+ return v.failures.map((f) => ({
1591
+ step: reasonOf(f.step) || toolName,
1592
+ itemId: typeof f.itemId === 'string' ? f.itemId : null,
1593
+ reason: reasonOf(f.reason) || reasonOf(f.error) || message,
1594
+ }));
1595
+ }
1596
+ return [{
1597
+ step: (v && typeof v === 'object' && reasonOf(v.step)) || toolName,
1598
+ itemId: v && typeof v === 'object' && typeof v.itemId === 'string' ? v.itemId : null,
1599
+ reason: (v && typeof v === 'object' && (reasonOf(v.reason) || reasonOf(v.error))) || reasonOf(message) || `${toolName} failed with no reason given`,
1600
+ }];
1601
+ }
1437
1602
  export function innerFailure(value) {
1438
1603
  if (!value || typeof value !== 'object')
1439
1604
  return null;
@@ -1605,6 +1770,15 @@ export const HIDDEN_ARGUMENTS = {
1605
1770
  pinepaper_query_capabilities: ['query'],
1606
1771
  pinepaper_text_effect: ['startAt', 'hideAfter'],
1607
1772
  };
1773
+ /**
1774
+ * Nested keys (paths with [] for any array index) a handler accepts that its published schema closes over without
1775
+ * listing. Shrink-only, held exact by nested-ignored-coverage.test.ts; publishing one means deleting it here.
1776
+ */
1777
+ export const NESTED_HIDDEN = {
1778
+ pinepaper_create_item: ['keyframes[].hold', 'keyframes[].interpolation'],
1779
+ pinepaper_create_scene: ['animations[].startTime'],
1780
+ pinepaper_keyframe_animate: ['keyframes[].hold', 'keyframes[].interpolation'],
1781
+ };
1608
1782
  /** Tools whose schema passes every key through: nothing is dropped. */
1609
1783
  export const PASSTHROUGH_ARGUMENT_TOOLS = new Set(['pinepaper_equation_path', 'pinepaper_ui_kit']);
1610
1784
  const IGNORED_ARGUMENT_HINTS = {
@@ -1635,8 +1809,9 @@ export function ignoredArgumentKeys(toolName, args, published) {
1635
1809
  return [];
1636
1810
  const known = new Set([...(published ?? []), ...(HIDDEN_ARGUMENTS[toolName] ?? [])]);
1637
1811
  let schema = IGNORED_ARGUMENT_SCHEMAS[toolName];
1638
- while (schema instanceof z.ZodEffects)
1639
- schema = schema._def.schema;
1812
+ // zod 4: a preprocess or transform is a ZodPipe (preprocess: transform → object; .transform: object → transform).
1813
+ while (schema instanceof z.ZodPipe)
1814
+ schema = (schema._def.in instanceof z.ZodObject || !(schema._def.out instanceof z.ZodObject) ? schema._def.in : schema._def.out);
1640
1815
  if (schema instanceof z.ZodObject)
1641
1816
  Object.keys(schema.shape).forEach((k) => known.add(k));
1642
1817
  else if (published === undefined)
@@ -1651,13 +1826,66 @@ export function ignoredArgumentsNote(toolName, args, published) {
1651
1826
  return `IGNORED ARGUMENTS: ${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not a parameter of ${toolName}, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect.`
1652
1827
  + (hints.length ? ` ${[...new Set(hints)].join(' ')}` : '');
1653
1828
  }
1654
- /** The same check, against the tool's published schema. */
1829
+ /** The same check, against the tool's published schema, and below the top level (AR-1 R2: nested-ignored.ts). */
1655
1830
  export async function ignoredArgumentsNoteFor(toolName, args) {
1656
1831
  const published = await publishedArguments(toolName);
1657
- const ignored = ignoredArgumentKeys(toolName, args, published);
1832
+ const top = ignoredArgumentKeys(toolName, args, published);
1833
+ const nested = PASSTHROUGH_ARGUMENT_TOOLS.has(toolName) || published === null ? [] : await nestedIgnoredFor(toolName, args);
1834
+ const ignored = [...top, ...nested];
1658
1835
  if (!ignored.length)
1659
1836
  return null;
1660
- return { note: ignoredArgumentsNote(toolName, args, published), ignored };
1837
+ const topNote = top.length ? ignoredArgumentsNote(toolName, args, published) : '';
1838
+ const nestedNote = nested.length
1839
+ ? `IGNORED ARGUMENTS: ${nested.join(', ')} ${nested.length > 1 ? 'are' : 'is'} not a parameter of ${toolName}, so ${nested.length > 1 ? 'they had' : 'it had'} no effect.`
1840
+ : '';
1841
+ return { note: [topNote, nestedNote].filter(Boolean).join(' '), ignored };
1842
+ }
1843
+ async function nestedIgnoredFor(toolName, args) {
1844
+ const { PINEPAPER_TOOLS } = await import('./definitions.js');
1845
+ const tool = PINEPAPER_TOOLS.find((t) => t.name === toolName);
1846
+ if (!tool)
1847
+ return [];
1848
+ const hidden = new Set(NESTED_HIDDEN[toolName] ?? []);
1849
+ const paths = nestedIgnoredPaths(tool.inputSchema, args).filter((p) => !hidden.has(p.replace(/\[\d+\]/g, '[]')));
1850
+ return toolName === 'pinepaper_agent_batch_execute' ? [...paths, ...batchOpIgnoredPaths(args)] : paths;
1851
+ }
1852
+ /**
1853
+ * Batch op keys the batch reads that AgentBatchOperationSchema does not list. The op schema is passthrough (a create
1854
+ * takes shape properties flat), so these arrive intact; batch-op-keys.test.ts fails when the batch code reads a key
1855
+ * that is in neither list, so a working key is never reported. Shrink-only.
1856
+ */
1857
+ export const BATCH_OP_HIDDEN = {
1858
+ '*': ['_step', 'id'],
1859
+ keyframe_animate: ['params', 'timeUnits', 'timeOffset', 'clipInPoint', 'clipOutPoint'],
1860
+ timeline: ['repeat', 'yoyo', 'anchor'],
1861
+ };
1862
+ /** Hints for the slips that land on a key another op type reads. */
1863
+ const BATCH_OP_HINTS = {
1864
+ 'relation.params': 'a relation op takes its parameters as relationOptions',
1865
+ 'modify.x': 'a modify op takes changes inside properties', 'modify.y': 'a modify op takes changes inside properties',
1866
+ };
1867
+ /**
1868
+ * A key on a batch op that nothing reads (the passthrough schema let it in, so the nested walk cannot see it). A
1869
+ * `create` folds every op-level key into its properties, which create_item's own property report covers, so only the
1870
+ * other op types are checked here.
1871
+ */
1872
+ export function batchOpIgnoredPaths(args) {
1873
+ const ops = Array.isArray(args.operations) ? args.operations : [];
1874
+ const shape = Object.keys(AgentBatchOperationSchema.shape);
1875
+ const out = [];
1876
+ ops.forEach((op, i) => {
1877
+ const type = op && typeof op === 'object' && !Array.isArray(op) ? String(op.type) : 'create';
1878
+ if (type === 'create')
1879
+ return;
1880
+ const known = new Set([...shape, ...BATCH_OP_HIDDEN['*'], ...(BATCH_OP_HIDDEN[type] ?? [])]);
1881
+ for (const k of Object.keys(op)) {
1882
+ if (known.has(k))
1883
+ continue;
1884
+ const hint = BATCH_OP_HINTS[`${type}.${k}`];
1885
+ out.push(`operations[${i}].${k}${hint ? ` (${hint})` : ''}`);
1886
+ }
1887
+ });
1888
+ return out.slice(0, 50);
1661
1889
  }
1662
1890
  export const IGNORED_ARGUMENTS_META_KEY = 'pinepaper.studio/ignoredArguments';
1663
1891
  export async function handleToolCall(toolName, args, options = {}) {
@@ -1798,6 +2026,11 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1798
2026
  const code = codeGenerator.generateStagger(input);
1799
2027
  return executeOrGenerate(code, `Stagger: ${input.action}`, options, 'pinepaper_stagger');
1800
2028
  }
2029
+ case 'pinepaper_pattern': {
2030
+ const input = PatternInputSchema.parse(args);
2031
+ const code = codeGenerator.generatePattern(input);
2032
+ return executeOrGenerate(code, `Pattern: ${input.action}${input.id ? ' ' + input.id : ''}`, options, 'pinepaper_pattern');
2033
+ }
1801
2034
  case 'pinepaper_flip': {
1802
2035
  const input = FlipInputSchema.parse(args);
1803
2036
  const code = codeGenerator.generateFlip(input);
@@ -2396,7 +2629,7 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
2396
2629
  const controller = options.browserController || getBrowserController();
2397
2630
  if (!controller.connected)
2398
2631
  await controller.connect();
2399
- const r = await controller.executeCode(`(function() { const ids = ${JSON.stringify(itemIds)}; return ids.filter(function(id) { const e = app.itemRegistry && app.itemRegistry.get(id); return !(e && e.item); }); })();`, false);
2632
+ const r = await controller.executeCode(`(function() { const ids = ${jsLiteral(itemIds)}; return ids.filter(function(id) { const e = app.itemRegistry && app.itemRegistry.get(id); return !(e && e.item); }); })();`, false);
2400
2633
  if (r.success && Array.isArray(r.result))
2401
2634
  missingItems = r.result;
2402
2635
  }
@@ -2840,7 +3073,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
2840
3073
  // Falls back to the PINEPAPER_STUDIO_URL env var, else PuppeteerController's
2841
3074
  // built-in prod default. "/editor" + agent params are appended by the controller.
2842
3075
  const rawUrl = typeof args.url === 'string' ? args.url.trim() : '';
2843
- const studioUrl = (rawUrl || (process.env.PINEPAPER_STUDIO_URL || '').trim()) || undefined;
3076
+ const studioUrl = (rawUrl || (env('PINEPAPER_STUDIO_URL') || '').trim()) || undefined;
2844
3077
  // Only http(s) is supported — reject file://, data://, javascript:, etc.
2845
3078
  if (studioUrl) {
2846
3079
  const v = validateStudioUrl(studioUrl);
@@ -2993,7 +3226,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
2993
3226
  version: SERVER_VERSION,
2994
3227
  toolkit: detectToolkit(),
2995
3228
  verbosity: detectVerbosity(),
2996
- locale: process.env.PINEPAPER_LOCALE || 'en',
3229
+ locale: env('PINEPAPER_LOCALE') || 'en',
2997
3230
  executionMode: options.executionMode || getExecutionMode(),
2998
3231
  screenshotMode: options.screenshotMode || getScreenshotMode(),
2999
3232
  };
@@ -3095,6 +3328,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3095
3328
  })),
3096
3329
  profiles: {
3097
3330
  'full+verbose': fullTokens,
3331
+ 'core+compact': Math.ceil(Buffer.byteLength(JSON.stringify(getToolsForToolkit(getToolsForVerbosity('compact'), 'core')), 'utf-8') / 4),
3098
3332
  'agent+compact': agentCompactTokens,
3099
3333
  'agent+minimal': agentMinimalTokens,
3100
3334
  },
@@ -3107,7 +3341,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3107
3341
  const recs = toolDefsSection.recommendations;
3108
3342
  const alternatives = [];
3109
3343
  for (const v of ['compact', 'minimal']) {
3110
- for (const k of ['full', 'agent', 'minimal']) {
3344
+ for (const k of ['full', 'agent', 'core', 'minimal']) {
3111
3345
  if (k === toolkit && v === verbosity)
3112
3346
  continue;
3113
3347
  const t = Math.ceil(Buffer.byteLength(JSON.stringify(getToolsForToolkit(getToolsForVerbosity(v), k)), 'utf-8') / 4);
@@ -3549,7 +3783,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3549
3783
  type: 'text',
3550
3784
  // The script is not part of the answer here either — same reason
3551
3785
  // errors no longer carry it. PINEPAPER_ECHO_CODE=1 brings it back.
3552
- text: (process.env.PINEPAPER_ECHO_CODE === '1'
3786
+ text: (env('PINEPAPER_ECHO_CODE') === '1'
3553
3787
  ? `Executed PinePaper code:\n\n\`\`\`javascript\n${code}\n\`\`\`\n\n`
3554
3788
  : '')
3555
3789
  + `Full screenshot saved to: ${filePath} (${(fileSize / 1024).toFixed(1)} KB)\n\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
@@ -3611,8 +3845,26 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3611
3845
  })));
3612
3846
  }
3613
3847
  }
3848
+ let expanded;
3849
+ try {
3850
+ expanded = expandBatchMacros(input.operations);
3851
+ }
3852
+ catch (e) {
3853
+ if (e instanceof BatchMacroError)
3854
+ return errorResult(ErrorCodes.VALIDATION_ERROR, e.message, {}, { toolName: 'pinepaper_agent_batch_execute' });
3855
+ throw e;
3856
+ }
3857
+ // A timeline op compiles on the server: a tween it cannot honour is refused by name here, before the canvas is
3858
+ // touched (skipValidation does not bypass it — there is nothing to send for a tween that did not compile).
3859
+ if (expanded.some((op) => op.type === 'timeline')) {
3860
+ const tl = compileBatchTimelines(expanded);
3861
+ if (tl.errors.length > 0) {
3862
+ return errorResult(ErrorCodes.VALIDATION_ERROR, `The timeline was refused (${tl.errors.length} problem${tl.errors.length > 1 ? 's' : ''}); nothing was run. Fix ${tl.errors.length > 1 ? 'each' : 'it'} and send the batch again.`, tl.errors.map((e) => ({ path: e.path, reason: e.message })));
3863
+ }
3864
+ }
3614
3865
  const code = codeGenerator.generateAgentBatchExecute(input);
3615
- const description = `Batch executed ${input.operations.length} operations${input.atomic !== false ? ' (atomic)' : ''}`;
3866
+ const description = `Batch executed ${input.operations.length} operations${input.atomic !== false ? ' (atomic)' : ''}`
3867
+ + (expanded.length !== input.operations.length ? ` (${expanded.length} after expanding ring / grid / orbit / stagger)` : '');
3616
3868
  // Check for out-of-bounds items before execution
3617
3869
  let boundsWarning = '';
3618
3870
  const sizeOp = input.operations.find((op) => op.type === 'set_canvas_size');
@@ -3620,7 +3872,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3620
3872
  const cw = sizeOp.width || 1920;
3621
3873
  const ch = sizeOp.height || 1080;
3622
3874
  const oobItems = [];
3623
- input.operations.forEach((op, idx) => {
3875
+ expanded.forEach((op, idx) => {
3624
3876
  if (op.type === 'create' && op.position) {
3625
3877
  const { x, y } = op.position;
3626
3878
  if (x < 0 || y < 0 || x > cw || y > ch) {
@@ -3632,7 +3884,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3632
3884
  boundsWarning = `\n\nWARNING: ${oobItems.length} item(s) positioned outside canvas bounds (${cw}x${ch}): ${oobItems.join(', ')}. Items may be clipped or invisible.`;
3633
3885
  }
3634
3886
  }
3635
- const batchResult = await executeOrGenerate(code, description, options, 'pinepaper_agent_batch_execute');
3887
+ const batchResult = await executeOrGenerate(code, description, input.atomic !== false ? { ...options, rollbackCreatedOnFailure: true } : options, 'pinepaper_agent_batch_execute');
3636
3888
  // Append bounds warning if any
3637
3889
  if (boundsWarning && batchResult.content && batchResult.content.length > 0) {
3638
3890
  const firstContent = batchResult.content[0];
@@ -3804,13 +4056,14 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3804
4056
  format,
3805
4057
  chunkBytes: Number(exportResult.chunkBytes) || undefined,
3806
4058
  }, input.platform || 'auto');
3807
- const cleanResult = { ...exportResult, data: undefined, filePath, fileSize, chunks };
3808
- return {
4059
+ const receipt = await exportFileReceiptOrReason(filePath, exportResult);
4060
+ const cleanResult = { ...exportResult, data: undefined, filePath, fileSize, chunks, file: receipt };
4061
+ return withEngineReport({
3809
4062
  content: [{
3810
4063
  type: 'text',
3811
- text: `Export saved to file:\n\nFile: ${filePath}\nFormat: ${format}\nSize: ${(fileSize / 1024).toFixed(1)} KB\nPlatform: ${input.platform}\nPaged out of the studio's export store in ${chunks} chunk${chunks === 1 ? '' : 's'}\n\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
3812
- }],
3813
- };
4064
+ text: `Export saved to file:\n\nFile: ${filePath}\nFormat: ${format}\nSize: ${(fileSize / 1024).toFixed(1)} KB\nPlatform: ${input.platform}\nPaged out of the studio's export store in ${chunks} chunk${chunks === 1 ? '' : 's'}\n${receiptErrorLine(receipt)}${'receiptError' in receipt ? '' : probeLines(receipt)}${lintLines(exportResult)}\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
4065
+ }, exportFileLink(filePath, fileSize)],
4066
+ }, exportBrowserResult.report);
3814
4067
  }
3815
4068
  catch (streamError) {
3816
4069
  // The export is still held — the message says so and names the id,
@@ -3824,7 +4077,14 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3824
4077
  // SCC (CEA-608, 8.37): the studio sent the cues; encoded here.
3825
4078
  if (format === 'scc' && Array.isArray(exportResult?.sccCues)) {
3826
4079
  const scc = buildScc(exportResult.sccCues);
3827
- const { filePath, fileSize } = await saveExportToFile(scc.text, 'scc', input.platform || 'auto');
4080
+ let saved;
4081
+ try {
4082
+ saved = await saveExportToFile(scc.text, 'scc', input.platform || 'auto');
4083
+ }
4084
+ catch (saveError) {
4085
+ return exportStepErrorResult(saveError, 'save', { format: 'scc' });
4086
+ }
4087
+ const { filePath, fileSize } = saved;
3828
4088
  const warnings = [];
3829
4089
  if (scc.truncated)
3830
4090
  warnings.push({ code: 'caption_truncated', message: `${scc.truncated} caption(s) did not fit two rows of 32 characters and were cut. Split long captions into more cues.` });
@@ -3835,10 +4095,22 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3835
4095
  const { sccCues: _c, ...rest } = exportResult;
3836
4096
  void _c;
3837
4097
  const cleanResult = { ...rest, filePath, fileSize, frameRate: '29.97 drop-frame', fidelity: { warnings } };
3838
- return { content: [{ type: 'text', text: `Export saved to file:\n\nFile: ${filePath}\nFormat: scc (CEA-608, CC1, pop-on)\nSize: ${fileSize} bytes\n\nResult: ${JSON.stringify(cleanResult, null, 2)}` }] };
4098
+ return withEngineReport({ content: [{ type: 'text', text: `Export saved to file:\n\nFile: ${filePath}\nFormat: scc (CEA-608, CC1, pop-on)\nSize: ${fileSize} bytes\n\nResult: ${JSON.stringify(cleanResult, null, 2)}` }, exportFileLink(filePath, fileSize)] }, exportBrowserResult.report);
3839
4099
  }
3840
4100
  if ((format === 'html5-ad' || format === 'playable') && typeof exportResult?.adPage === 'string') {
3841
- return saveAdExport(exportResult, input, input.platform || 'auto');
4101
+ try {
4102
+ return withEngineReport(await saveAdExport(exportResult, input, input.platform || 'auto'), exportBrowserResult.report);
4103
+ }
4104
+ catch (saveError) {
4105
+ return exportStepErrorResult(saveError, 'save', { format });
4106
+ }
4107
+ }
4108
+ // NO SUCCESS WITHOUT BYTES. Every delivering branch above returned; what is left must carry the file's
4109
+ // data. A success with none (no data field, an empty string) used to fall through to the inline return and
4110
+ // report success with nothing in it. An estimate is the one answer that is not a file.
4111
+ if (!exportResult?.estimateOnly && !(typeof data === 'string' && data.length > 0)) {
4112
+ const err = new ExportStepError('export', null, `the studio reported a successful ${format} export but returned no file data (${data === undefined ? 'no data field' : typeof data === 'string' ? 'an empty string' : `a ${data === null ? 'null' : typeof data}`})`);
4113
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, { ...err.toData(), format, result: exportResult }, { toolName: 'pinepaper_agent_export' });
3842
4114
  }
3843
4115
  const shouldSaveToFile = data && typeof data === 'string' && (options.saveToFile ||
3844
4116
  ALWAYS_SAVE_FORMATS.has(format) ||
@@ -3846,29 +4118,33 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3846
4118
  if (shouldSaveToFile) {
3847
4119
  try {
3848
4120
  const { filePath, fileSize } = await saveExportToFile(data, format, input.platform || 'auto');
3849
- const cleanResult = { ...exportResult, data: undefined, filePath, fileSize };
4121
+ const receipt = await exportFileReceiptOrReason(filePath, exportResult);
4122
+ const cleanResult = { ...exportResult, data: undefined, filePath, fileSize, file: receipt };
3850
4123
  // A mix that dropped sounds wrote a perfectly good file that is
3851
4124
  // missing tracks. It rides the SUCCESS path, which is exactly why
3852
4125
  // it has to be said out loud here rather than left in the JSON.
3853
4126
  const dropNote = exportResult.incomplete;
3854
- return {
4127
+ return withEngineReport({
3855
4128
  content: [{
3856
4129
  type: 'text',
3857
4130
  text: `Export saved to file:\n\nFile: ${filePath}\nFormat: ${format}\nSize: ${(fileSize / 1024).toFixed(1)} KB\nPlatform: ${input.platform}\n`
3858
4131
  + (dropNote ? `DROPPED: ${dropNote}\n` : '')
4132
+ + receiptErrorLine(receipt)
4133
+ + ('receiptError' in receipt ? '' : probeLines(receipt))
4134
+ + lintLines(exportResult)
3859
4135
  + `\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
3860
- }],
3861
- };
4136
+ }, exportFileLink(filePath, fileSize)],
4137
+ }, exportBrowserResult.report);
3862
4138
  }
3863
4139
  catch (saveError) {
4140
+ // A failed save is a failed export, never a success with a note inside it: the step, the path and
4141
+ // the reason, and the data stripped so the error is not the size of the video.
3864
4142
  console.error('[PinePaper] Failed to save export to file:', saveError);
3865
- // Strip the massive data to prevent oversized response
3866
- const strippedResult = { ...exportResult, data: `[${format} export data — file save failed: ${saveError instanceof Error ? saveError.message : 'unknown error'}]` };
3867
- return executedResult(code, strippedResult, exportBrowserResult.screenshot, description);
4143
+ return exportStepErrorResult(saveError, 'save', { format, bytes: typeof data === 'string' ? data.length : 0 });
3868
4144
  }
3869
4145
  }
3870
- // Small export — return inline (existing behavior)
3871
- return executedResult(code, exportResult, exportBrowserResult.screenshot, description);
4146
+ // Small export — return inline (existing behavior). The bytes are in the result, checked non-empty above.
4147
+ return executedResult(code, exportResult, exportBrowserResult.screenshot, description, exportBrowserResult.report);
3872
4148
  }
3873
4149
  case 'pinepaper_design_styles':
3874
4150
  return dataResult({ styles: designSystems.listStyles() });
@@ -4039,11 +4315,8 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
4039
4315
  };
4040
4316
  }
4041
4317
  catch (saveError) {
4042
- const stripped = {
4043
- ...soundtrack,
4044
- wavBase64: `[${soundtrack.byteLength} bytes of WAV — file save failed: ${saveError instanceof Error ? saveError.message : 'unknown error'}]`,
4045
- };
4046
- return executedResult(code, stripped, wavResult.screenshot, soundDescription);
4318
+ // The soundtrack rendered; writing it did not. That is a failed export, said as one.
4319
+ return exportStepErrorResult(saveError, 'save', { format: 'wav', byteLength: soundtrack.byteLength }, 'pinepaper_sound');
4047
4320
  }
4048
4321
  }
4049
4322
  return executeOrGenerate(code, soundDescription, options, 'pinepaper_sound');
@@ -4292,7 +4565,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
4292
4565
  }
4293
4566
  catch (error) {
4294
4567
  if (error instanceof ZodError) {
4295
- return handleValidationError(error, i18n);
4568
+ return handleValidationError(error, i18n, args);
4296
4569
  }
4297
4570
  // Try to capture canvas state for error context (if browser is connected)
4298
4571
  let canvasState = null;
@@ -4305,6 +4578,10 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
4305
4578
  catch (_stateError) {
4306
4579
  // Ignore errors from canvas state capture
4307
4580
  }
4581
+ // An export that failed at a named step keeps its step, path and reason as fields.
4582
+ if (error instanceof ExportStepError) {
4583
+ return errorResult(ErrorCodes.EXECUTION_ERROR, error.message, error.toData(), { toolName, canvasState: canvasState || undefined });
4584
+ }
4308
4585
  if (error instanceof Error) {
4309
4586
  const message = i18n
4310
4587
  ? i18n.getError('executionError', { message: error.message })