@pinepaper.studio/mcp-server 1.6.23 → 1.6.24

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 (105) 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 +84 -5
  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.js +2 -2
  16. package/dist/browser/puppeteer-controller.js.map +1 -1
  17. package/dist/build-info.json +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +13 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/ontology/hints.d.ts.map +1 -1
  22. package/dist/ontology/hints.js +5 -1
  23. package/dist/ontology/hints.js.map +1 -1
  24. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  25. package/dist/ontology/upstream-vocabulary.js +1 -1
  26. package/dist/ontology/vocabulary.d.ts.map +1 -1
  27. package/dist/ontology/vocabulary.js +1 -0
  28. package/dist/ontology/vocabulary.js.map +1 -1
  29. package/dist/tools/batch-macros.d.ts +27 -0
  30. package/dist/tools/batch-macros.d.ts.map +1 -0
  31. package/dist/tools/batch-macros.js +157 -0
  32. package/dist/tools/batch-macros.js.map +1 -0
  33. package/dist/tools/choreograph.d.ts.map +1 -1
  34. package/dist/tools/choreograph.js +2 -0
  35. package/dist/tools/choreograph.js.map +1 -1
  36. package/dist/tools/compact-descriptions.js +3 -3
  37. package/dist/tools/compact-descriptions.js.map +1 -1
  38. package/dist/tools/definitions.d.ts.map +1 -1
  39. package/dist/tools/definitions.js +87 -5
  40. package/dist/tools/definitions.js.map +1 -1
  41. package/dist/tools/engine-lists.d.ts +2 -2
  42. package/dist/tools/engine-lists.js +2 -2
  43. package/dist/tools/engine-methods.d.ts.map +1 -1
  44. package/dist/tools/engine-methods.js +1 -0
  45. package/dist/tools/engine-methods.js.map +1 -1
  46. package/dist/tools/engine-surface.d.ts +1 -1
  47. package/dist/tools/engine-surface.d.ts.map +1 -1
  48. package/dist/tools/engine-surface.js +52 -6
  49. package/dist/tools/engine-surface.js.map +1 -1
  50. package/dist/tools/export-file-check.d.ts +31 -0
  51. package/dist/tools/export-file-check.d.ts.map +1 -0
  52. package/dist/tools/export-file-check.js +103 -0
  53. package/dist/tools/export-file-check.js.map +1 -0
  54. package/dist/tools/export-file-receipt.d.ts +56 -0
  55. package/dist/tools/export-file-receipt.d.ts.map +1 -0
  56. package/dist/tools/export-file-receipt.js +151 -0
  57. package/dist/tools/export-file-receipt.js.map +1 -0
  58. package/dist/tools/handlers/beats.d.ts +7 -44
  59. package/dist/tools/handlers/beats.d.ts.map +1 -1
  60. package/dist/tools/handlers/export.d.ts.map +1 -1
  61. package/dist/tools/handlers/export.js +31 -6
  62. package/dist/tools/handlers/export.js.map +1 -1
  63. package/dist/tools/handlers/generate.d.ts +41 -270
  64. package/dist/tools/handlers/generate.d.ts.map +1 -1
  65. package/dist/tools/handlers.d.ts +67 -1
  66. package/dist/tools/handlers.d.ts.map +1 -1
  67. package/dist/tools/handlers.js +304 -67
  68. package/dist/tools/handlers.js.map +1 -1
  69. package/dist/tools/lane.d.ts +34 -0
  70. package/dist/tools/lane.d.ts.map +1 -0
  71. package/dist/tools/lane.js +210 -0
  72. package/dist/tools/lane.js.map +1 -0
  73. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  74. package/dist/tools/minimal-descriptions.js +3 -2
  75. package/dist/tools/minimal-descriptions.js.map +1 -1
  76. package/dist/tools/nested-ignored.d.ts +16 -0
  77. package/dist/tools/nested-ignored.d.ts.map +1 -0
  78. package/dist/tools/nested-ignored.js +83 -0
  79. package/dist/tools/nested-ignored.js.map +1 -0
  80. package/dist/tools/shape-params.d.ts +2 -2
  81. package/dist/tools/shape-params.d.ts.map +1 -1
  82. package/dist/tools/shape-params.js +9 -2
  83. package/dist/tools/shape-params.js.map +1 -1
  84. package/dist/tools/stick-vocabulary.d.ts +4 -4
  85. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  86. package/dist/tools/stick-vocabulary.js +8 -4
  87. package/dist/tools/stick-vocabulary.js.map +1 -1
  88. package/dist/tools/timeline.d.ts +136 -0
  89. package/dist/tools/timeline.d.ts.map +1 -0
  90. package/dist/tools/timeline.js +1036 -0
  91. package/dist/tools/timeline.js.map +1 -0
  92. package/dist/tools/toolkits.d.ts +1 -1
  93. package/dist/tools/toolkits.d.ts.map +1 -1
  94. package/dist/tools/toolkits.js +12 -2
  95. package/dist/tools/toolkits.js.map +1 -1
  96. package/dist/types/code-generator.d.ts +30 -2
  97. package/dist/types/code-generator.d.ts.map +1 -1
  98. package/dist/types/code-generator.js +277 -53
  99. package/dist/types/code-generator.js.map +1 -1
  100. package/dist/types/schemas.d.ts +5276 -25048
  101. package/dist/types/schemas.d.ts.map +1 -1
  102. package/dist/types/schemas.js +78 -40
  103. package/dist/types/schemas.js.map +1 -1
  104. package/manifest.json +5 -1
  105. package/package.json +8 -7
@@ -5,8 +5,10 @@
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,
@@ -26,12 +28,14 @@ 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 { exportFileReceipt } 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
41
  import { tmpdir } from 'node:os';
@@ -44,6 +48,7 @@ import { formatErrorContext, captureCanvasState } from '../execution/index.js';
44
48
  import { FILTER_PARAM_RANGES } from './engine-lists.js';
45
49
  import { getSessionManager } from '../agent/session-manager.js';
46
50
  import { vocabularyHintForPath, validateBatchVocabulary, detectBatchPropertyTypos } from '../ontology/hints.js';
51
+ import { compileBatchTimelines } from './timeline.js';
47
52
  import { cameraHandlers } from './handlers/camera.js';
48
53
  import { fontHandlers } from './handlers/font.js';
49
54
  import { toolGuideHandlers } from './handlers/tool-guide.js';
@@ -372,12 +377,15 @@ async function saveAdExport(r, input, platform) {
372
377
  const html = buildHtml5Ad(r.adPage, opts);
373
378
  const zip = buildZip([{ name: 'index.html', data: html }]);
374
379
  files.zip = join(exportDir, `${stamp}.zip`);
375
- await writeFile(files.zip, zip);
380
+ await writeStep(files.zip, () => writeFile(files.zip, zip));
381
+ await verifySavedFile(files.zip, zip.length);
376
382
  uploadBytes = zip.length;
377
383
  // The backup image is uploaded BESIDE the zip, not inside it.
378
384
  if (r.backupImage && r.backupImage.startsWith('data:')) {
379
385
  files.backupImage = join(exportDir, `${stamp}_backup.png`);
380
- await writeFile(files.backupImage, Buffer.from(r.backupImage.split(',')[1], 'base64'));
386
+ const backup = bytesForSave(r.backupImage, 'the backup image');
387
+ await writeStep(files.backupImage, () => writeFile(files.backupImage, backup));
388
+ await verifySavedFile(files.backupImage, backup.length);
381
389
  }
382
390
  else {
383
391
  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 +394,9 @@ async function saveAdExport(r, input, platform) {
386
394
  else {
387
395
  const html = buildPlayable(r.adPage, opts);
388
396
  files.html = join(exportDir, `${stamp}.html`);
389
- await writeFile(files.html, html, 'utf-8');
397
+ await writeStep(files.html, () => writeFile(files.html, html, 'utf-8'));
390
398
  uploadBytes = Buffer.byteLength(html, 'utf-8');
399
+ await verifySavedFile(files.html, uploadBytes);
391
400
  }
392
401
  // 150 KB is Google Ads' display HTML5 figure; some uploads allow 600 KB, and
393
402
  // playable networks set their own — so it is a default, not a rule.
@@ -416,22 +425,26 @@ async function saveAdExport(r, input, platform) {
416
425
  }
417
426
  async function saveExportToFile(data, format, platform) {
418
427
  const exportDir = getExportDir();
419
- await mkdir(exportDir, { recursive: true });
428
+ await writeStep(exportDir, () => mkdir(exportDir, { recursive: true }));
420
429
  const ext = getFileExtension(format);
421
430
  const timestamp = Date.now();
422
431
  const fileName = `pinepaper_${platform}_${timestamp}.${ext}`;
423
432
  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 };
433
+ // A data URL is decoded (base64 or percent-encoded); plain text (SVG, captions) is written as UTF-8. Nothing to
434
+ // write is the `export` or `save` step's failure, named — it used to write a 0-byte file and report it saved.
435
+ let buffer;
436
+ try {
437
+ buffer = bytesForSave(data, `the ${format} export`);
429
438
  }
430
- else {
431
- // Plain text (SVG)
432
- await writeFile(filePath, data, 'utf-8');
433
- return { filePath, fileSize: Buffer.byteLength(data, 'utf-8') };
439
+ catch (e) {
440
+ if (e instanceof ExportStepError)
441
+ throw new ExportStepError(e.step, filePath, e.reason);
442
+ throw e;
434
443
  }
444
+ await writeStep(filePath, () => writeFile(filePath, buffer));
445
+ // Read back: success is a file that exists and holds what was written.
446
+ await verifySavedFile(filePath, buffer.length);
447
+ return { filePath, fileSize: buffer.length };
435
448
  }
436
449
  /**
437
450
  * Page a RETAINED export out of the studio's export store into a local file.
@@ -459,15 +472,16 @@ async function streamRetainedExportToFile(controller, retained, platform) {
459
472
  let offset = 0;
460
473
  let chunks = 0;
461
474
  // Start from empty: appendFile would otherwise extend a same-millisecond file.
462
- await writeFile(filePath, Buffer.alloc(0));
475
+ await writeStep(filePath, () => writeFile(filePath, Buffer.alloc(0)));
463
476
  // Returns the error rather than throwing it, so `throw await abandon(...)`
464
477
  // reads as a throw to the type checker and narrows what follows.
465
- const abandon = async (why) => {
478
+ // Paging the bytes out is the `save` step; a refusal from the file system is the `write` step.
479
+ const abandon = async (why, step = 'save') => {
466
480
  try {
467
481
  await unlink(filePath);
468
482
  }
469
483
  catch { /* nothing written yet */ }
470
- return new Error(
484
+ return new ExportStepError(step, filePath,
471
485
  // The promise now names the call that keeps it. It was true before and
472
486
  // unreachable: reading a held export mutates nothing, so "it can be paged
473
487
  // again" was always correct and there was no door to page it through.
@@ -546,7 +560,12 @@ async function streamRetainedExportToFile(controller, retained, platform) {
546
560
  const buf = Buffer.from(chunk.data || '', 'base64');
547
561
  if (buf.length === 0 && chunk.eof !== true)
548
562
  throw await abandon('the studio returned an empty chunk before the end of the file');
549
- await appendFile(filePath, buf);
563
+ try {
564
+ await appendFile(filePath, buf);
565
+ }
566
+ catch (writeError) {
567
+ throw await abandon(`appending ${buf.length} bytes at offset ${offset} failed: ${errorText(writeError, 'the file system gave no reason')}`, 'write');
568
+ }
550
569
  offset += buf.length;
551
570
  chunks++;
552
571
  if (chunk.eof === true)
@@ -557,6 +576,16 @@ async function streamRetainedExportToFile(controller, retained, platform) {
557
576
  if (offset !== retained.size) {
558
577
  throw await abandon(`reassembled ${offset} bytes but the studio reported ${retained.size}`);
559
578
  }
579
+ // An empty export is not a saved file, and neither is one the disk does not hold: read it back BEFORE the
580
+ // studio's copy is released, so a failure here still leaves the bytes recoverable.
581
+ if (offset === 0)
582
+ throw await abandon('the studio held an empty export (0 bytes): there is nothing to save');
583
+ try {
584
+ await verifySavedFile(filePath, offset);
585
+ }
586
+ catch (verifyError) {
587
+ throw await abandon(errorText(verifyError instanceof ExportStepError ? verifyError.reason : verifyError, 'the written file could not be read back'), 'write');
588
+ }
560
589
  // Only now is it safe to drop the studio's copy.
561
590
  try {
562
591
  await controller.executeCode(codeGenerator.generateReleaseExport(retained.exportId), false);
@@ -685,10 +714,40 @@ function withSessionNote(out, r) {
685
714
  }
686
715
  return out;
687
716
  }
717
+ export const RECEIPTS_META_KEY = 'pinepaper.studio/receipts';
718
+ /** The engine run's receipts (report.receipts: { calls, byDoor, notes }), when any call has something to say. */
719
+ export function engineReceipts(report) {
720
+ const r = report && typeof report === 'object' ? report.receipts : undefined;
721
+ if (!r || !Array.isArray(r.notes) || r.notes.length === 0)
722
+ return null;
723
+ return { calls: Number(r.calls) || 0, byDoor: r.byDoor ?? {}, notes: r.notes };
724
+ }
725
+ /**
726
+ * agent_export answered from the export's own result and never read the run's report (AR-1 R2): an export's engine
727
+ * warnings and receipts are now on the reply like every other tool's — a text line an agent reads, and _meta.
728
+ */
729
+ export function withEngineReport(result, report) {
730
+ if (!report || result.isError)
731
+ return result;
732
+ const receipts = engineReceipts(report);
733
+ const engineWarnings = Array.isArray(report.engineWarnings) ? report.engineWarnings.filter((w) => w && typeof w === 'object') : [];
734
+ if (!receipts && !engineWarnings.length)
735
+ return result;
736
+ const body = { ...(receipts ? { receipts } : {}), ...(engineWarnings.length ? { engineWarnings } : {}) };
737
+ return {
738
+ ...result,
739
+ content: [...(result.content ?? []), { type: 'text', text: `ENGINE RECEIPTS: ${JSON.stringify(body)}` }],
740
+ _meta: { ...(result._meta ?? {}), [RECEIPTS_META_KEY]: body },
741
+ };
742
+ }
688
743
  function executedResult(code, result, screenshot, description, report) {
689
744
  const resultObj = { success: true, result };
690
745
  if (description)
691
746
  resultObj.description = description;
747
+ // The scene lint after the code ran, when there is something to say.
748
+ const lint = report && Array.isArray(report.lintWarnings) ? report.lintWarnings : [];
749
+ if (lint.length)
750
+ resultObj.lintWarnings = lint;
692
751
  // Surface the governor's model-actionable feedback (app.runGenerated report):
693
752
  // warnings (e.g. "500 items created one-by-one — prefer a pattern"), item count,
694
753
  // and guard trips. Omitted entirely on pre-governor FxTool builds (no report).
@@ -711,6 +770,11 @@ function executedResult(code, result, screenshot, description, report) {
711
770
  governor.loopGuardTripped = true;
712
771
  if (Object.keys(governor).length)
713
772
  resultObj.governor = governor;
773
+ // AR-1 R2: what each engine call did with what it was given (FxTool js/core/Receipt.js) — only when a call
774
+ // renamed, fell back, dropped or warned; a run whose calls all applied as asked adds nothing.
775
+ const receipts = engineReceipts(report);
776
+ if (receipts)
777
+ resultObj.receipts = receipts;
714
778
  }
715
779
  // THE CODE ECHO WAS 90% OF THE RESPONSE, AND THE AGENT WROTE THE REQUEST.
716
780
  //
@@ -777,6 +841,44 @@ function redactGeneratedCode(details) {
777
841
  },
778
842
  };
779
843
  }
844
+ /**
845
+ * The studio's scene-lint warnings on an export (FxTool lintScene), one line each, so a centred title that runs
846
+ * out of the frame ("IGNAL" for "SIGNAL") is read on the success path rather than left in the JSON. Empty when none.
847
+ */
848
+ export function lintLines(exportResult) {
849
+ const w = exportResult?.lintWarnings;
850
+ if (!Array.isArray(w) || !w.length)
851
+ return '';
852
+ return w.map((x) => `LINT: ${String(x?.message ?? x?.code ?? 'scene lint warning')}\n`).join('');
853
+ }
854
+ /**
855
+ * The file as ffprobe read it, when it disagrees with what the studio encoded: one line per field. A summary
856
+ * line when they agree; nothing when there is no summary.
857
+ */
858
+ export function probeLines(receipt) {
859
+ const p = receipt.probe;
860
+ if (!p)
861
+ return '';
862
+ 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'}`;
863
+ const c = receipt.probeCheck;
864
+ if (!c)
865
+ return `${head}\n`;
866
+ if (c.tool === null)
867
+ return `${head} (not read back: ${c.reason})\n`;
868
+ if (c.ok)
869
+ return `${head} (ffprobe agrees)\n`;
870
+ if (!c.mismatches?.length)
871
+ return `${head}\nPROBE: ${c.reason ?? 'ffprobe could not confirm the file'}\n`;
872
+ 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('');
873
+ }
874
+ /**
875
+ * An export that failed at a step, as an error result: `{ step, path, reason }` in the data and in the words.
876
+ * A non-ExportStepError is named at `fallbackStep` with its own message, never "undefined".
877
+ */
878
+ function exportStepErrorResult(e, fallbackStep, extra = {}, toolName = 'pinepaper_agent_export') {
879
+ const err = e instanceof ExportStepError ? e : new ExportStepError(fallbackStep, null, errorText(e, `the ${fallbackStep} step failed with no reason given`));
880
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, { ...err.toData(), ...extra }, { toolName });
881
+ }
780
882
  export function errorResult(code, message, rawDetails, context) {
781
883
  // THE GENERATED SCRIPT IS NOT THE ERROR.
782
884
  //
@@ -811,15 +913,19 @@ export function errorResult(code, message, rawDetails, context) {
811
913
  isError: true,
812
914
  };
813
915
  }
814
- function handleValidationError(error, i18n) {
916
+ function handleValidationError(error, i18n, args) {
815
917
  const message = i18n
816
918
  ? i18n.getError('validationError', { message: 'Invalid input parameters' })
817
919
  : 'Invalid input parameters';
818
- const details = error.errors.map((e) => {
920
+ const details = error.issues.map((e) => {
819
921
  const path = e.path.join('.');
820
922
  const detail = { path, message: e.message };
821
- if (e.code === 'invalid_enum_value') {
822
- const received = e.received;
923
+ // zod 4 names a value outside an enum `invalid_value` and no longer carries what was sent; it is read back from the
924
+ // call's own arguments along the issue's path, so the vocabulary hint ("did you mean …") still has a word to match.
925
+ if (e.code === 'invalid_value') {
926
+ let received = args;
927
+ for (const k of e.path)
928
+ received = received && typeof received === 'object' ? received[k] : undefined;
823
929
  const hint = vocabularyHintForPath(path, received);
824
930
  if (hint)
825
931
  detail.vocabulary = hint;
@@ -1062,23 +1168,34 @@ async function writeResultDataToFile(out, ext) {
1062
1168
  return out;
1063
1169
  }
1064
1170
  const r = obj.result;
1065
- if (!r || r.data === undefined || r.data === null)
1171
+ if (!r || r.success === false)
1066
1172
  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');
1173
+ if (r.data === undefined || r.data === null) {
1174
+ // A result that says in fields that it wrote no file (png-sequence frames left in the page: `file: null` and
1175
+ // `why`) claims no file: it passes as it is. Any other success with nothing to write is the exit-0-without-file
1176
+ // case, named.
1177
+ if (r.file === null && typeof r.why === 'string' && r.why.trim())
1178
+ return out;
1179
+ const err = new ExportStepError('export', null, `the ${ext} export reported success but returned no file data`);
1180
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, { ...err.toData(), result: r });
1181
+ }
1074
1182
  const dir = getExportDir();
1075
- await mkdir(dir, { recursive: true });
1076
1183
  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)] };
1184
+ // No success without a file that exists and is not empty: each failure names its step, path and reason.
1185
+ try {
1186
+ const bytes = typeof r.data === 'string' ? bytesForSave(r.data, `the ${ext} export`) : Buffer.from(JSON.stringify(r.data), 'utf-8');
1187
+ await writeStep(filePath, async () => { await mkdir(dir, { recursive: true }); await writeFile(filePath, bytes); });
1188
+ await verifySavedFile(filePath, bytes.length);
1189
+ const { data: _d, ...rest } = r;
1190
+ void _d;
1191
+ obj.result = { ...rest, filePath, bytes: bytes.length };
1192
+ return { ...out, content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }, ...out.content.slice(1)] };
1193
+ }
1194
+ catch (e) {
1195
+ const err = e instanceof ExportStepError ? new ExportStepError(e.step, e.path ?? filePath, e.reason)
1196
+ : new ExportStepError('write', filePath, errorText(e, 'the file could not be written'));
1197
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, err.toData());
1198
+ }
1082
1199
  }
1083
1200
  /**
1084
1201
  * pinepaper_capture_frames with `sheet`: one tiled image of the film, saved
@@ -1282,8 +1399,9 @@ ${code}
1282
1399
  tracker.startTimer(`${timerId}_screenshot`);
1283
1400
  }
1284
1401
  // 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'
1402
+ // emitter is not (its verdict already describes what it left behind), except
1403
+ // an atomic batch, whose contract is that a refused call changed nothing.
1404
+ const rollbackOnFailure = (toolName === 'pinepaper_execute_custom_code' || options.rollbackCreatedOnFailure === true)
1287
1405
  && await markItemsBeforeRun(controller);
1288
1406
  const governorTimeoutMs = LONG_GOVERNOR_BUDGET_MS[toolName];
1289
1407
  // NO SILENT SUCCESS (effect-class.ts / effect-check.ts): fingerprint a
@@ -1308,6 +1426,10 @@ ${code}
1308
1426
  ...(governorTimeoutMs ? { governorTimeoutMs } : {}),
1309
1427
  });
1310
1428
  const browserDuration = tracker.endTimer(`${timerId}_browser_execution`);
1429
+ // THE SCENE LINT RIDES OUT OF BAND: an emitter that ran app.lintScene() returns it under __ppLint, and
1430
+ // execute_custom_code returns its own value under __ppValue. Both are unwrapped HERE, before any verdict reads the
1431
+ // result, so the code's own value is what is judged and returned, and the lint sits beside it.
1432
+ const lintWarnings = unwrapLint(result);
1311
1433
  // THE METRIC RECORDS THE CALL'S VERDICT, NOT THE TRANSPORT'S (gate H2,
1312
1434
  // 1.6.19). result.success only says the code RAN; an emitter that ran and
1313
1435
  // returned { success: false } is a failed call, and get_performance_metrics
@@ -1352,6 +1474,8 @@ ${code}
1352
1474
  // PP_LOOP_BUDGET, PP_TIMEOUT) so the model can correct on the next turn.
1353
1475
  {
1354
1476
  code, errorCode: result.errorCode, governorReport: result.report,
1477
+ failures: stepFailures(toolName, result.result, result.error || 'Failed to execute code in browser'),
1478
+ ...(lintWarnings.length ? { lintWarnings } : {}),
1355
1479
  ...(rollbackOnFailure ? await customCodeRollback(controller, result.error) : {}),
1356
1480
  }, {
1357
1481
  toolName,
@@ -1383,6 +1507,8 @@ ${code}
1383
1507
  .slice(0, 3);
1384
1508
  return withTiming(errorResult(ErrorCodes.EXECUTION_ERROR, said.length ? `${inner} The studio said: ${said.join(' | ')}` : inner, {
1385
1509
  code, result: result.result, governorReport: result.report,
1510
+ failures: stepFailures(toolName, result.result, inner),
1511
+ ...(lintWarnings.length ? { lintWarnings } : {}),
1386
1512
  ...(rollbackOnFailure ? await customCodeRollback(controller, inner) : {}),
1387
1513
  }, { toolName, canvasState: canvasState || undefined }), timing);
1388
1514
  }
@@ -1434,6 +1560,46 @@ ${code}
1434
1560
  * string with no `success: true` beside it is a failure too — it is what the
1435
1561
  * emitters' catch blocks return.
1436
1562
  */
1563
+ /**
1564
+ * Move an emitter's out-of-band scene lint (`__ppLint`) off its result, and unwrap execute_custom_code's own value
1565
+ * (`__ppValue`). The lint is also put on `run.report.lintWarnings`, where executedResult reads it. Returns the lint.
1566
+ */
1567
+ export function unwrapLint(run) {
1568
+ const r = run.result;
1569
+ if (!r || typeof r !== 'object' || Array.isArray(r) || !('__ppLint' in r))
1570
+ return [];
1571
+ const lint = Array.isArray(r.__ppLint) ? r.__ppLint : [];
1572
+ if ('__ppValue' in r)
1573
+ run.result = r.__ppValue;
1574
+ else {
1575
+ const { __ppLint: _l, ...rest } = r;
1576
+ void _l;
1577
+ run.result = rest;
1578
+ }
1579
+ if (lint.length)
1580
+ run.report = { ...(run.report ?? {}), lintWarnings: lint };
1581
+ return lint;
1582
+ }
1583
+ /**
1584
+ * Every failed step of a call as `{ step, itemId, reason }`, never a bare message: a batch's own `failures`, a
1585
+ * result that names its step, or the call itself with the item it named.
1586
+ */
1587
+ export function stepFailures(toolName, value, message) {
1588
+ const v = value;
1589
+ const reasonOf = (x) => (typeof x === 'string' && x.trim() ? x : '');
1590
+ if (v && typeof v === 'object' && Array.isArray(v.failures) && v.failures.length) {
1591
+ return v.failures.map((f) => ({
1592
+ step: reasonOf(f.step) || toolName,
1593
+ itemId: typeof f.itemId === 'string' ? f.itemId : null,
1594
+ reason: reasonOf(f.reason) || reasonOf(f.error) || message,
1595
+ }));
1596
+ }
1597
+ return [{
1598
+ step: (v && typeof v === 'object' && reasonOf(v.step)) || toolName,
1599
+ itemId: v && typeof v === 'object' && typeof v.itemId === 'string' ? v.itemId : null,
1600
+ reason: (v && typeof v === 'object' && (reasonOf(v.reason) || reasonOf(v.error))) || reasonOf(message) || `${toolName} failed with no reason given`,
1601
+ }];
1602
+ }
1437
1603
  export function innerFailure(value) {
1438
1604
  if (!value || typeof value !== 'object')
1439
1605
  return null;
@@ -1605,6 +1771,15 @@ export const HIDDEN_ARGUMENTS = {
1605
1771
  pinepaper_query_capabilities: ['query'],
1606
1772
  pinepaper_text_effect: ['startAt', 'hideAfter'],
1607
1773
  };
1774
+ /**
1775
+ * Nested keys (paths with [] for any array index) a handler accepts that its published schema closes over without
1776
+ * listing. Shrink-only, held exact by nested-ignored-coverage.test.ts; publishing one means deleting it here.
1777
+ */
1778
+ export const NESTED_HIDDEN = {
1779
+ pinepaper_create_item: ['keyframes[].hold', 'keyframes[].interpolation'],
1780
+ pinepaper_create_scene: ['animations[].startTime'],
1781
+ pinepaper_keyframe_animate: ['keyframes[].hold', 'keyframes[].interpolation'],
1782
+ };
1608
1783
  /** Tools whose schema passes every key through: nothing is dropped. */
1609
1784
  export const PASSTHROUGH_ARGUMENT_TOOLS = new Set(['pinepaper_equation_path', 'pinepaper_ui_kit']);
1610
1785
  const IGNORED_ARGUMENT_HINTS = {
@@ -1635,8 +1810,9 @@ export function ignoredArgumentKeys(toolName, args, published) {
1635
1810
  return [];
1636
1811
  const known = new Set([...(published ?? []), ...(HIDDEN_ARGUMENTS[toolName] ?? [])]);
1637
1812
  let schema = IGNORED_ARGUMENT_SCHEMAS[toolName];
1638
- while (schema instanceof z.ZodEffects)
1639
- schema = schema._def.schema;
1813
+ // zod 4: a preprocess or transform is a ZodPipe (preprocess: transform → object; .transform: object → transform).
1814
+ while (schema instanceof z.ZodPipe)
1815
+ schema = (schema._def.in instanceof z.ZodObject || !(schema._def.out instanceof z.ZodObject) ? schema._def.in : schema._def.out);
1640
1816
  if (schema instanceof z.ZodObject)
1641
1817
  Object.keys(schema.shape).forEach((k) => known.add(k));
1642
1818
  else if (published === undefined)
@@ -1651,13 +1827,27 @@ export function ignoredArgumentsNote(toolName, args, published) {
1651
1827
  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
1828
  + (hints.length ? ` ${[...new Set(hints)].join(' ')}` : '');
1653
1829
  }
1654
- /** The same check, against the tool's published schema. */
1830
+ /** The same check, against the tool's published schema, and below the top level (AR-1 R2: nested-ignored.ts). */
1655
1831
  export async function ignoredArgumentsNoteFor(toolName, args) {
1656
1832
  const published = await publishedArguments(toolName);
1657
- const ignored = ignoredArgumentKeys(toolName, args, published);
1833
+ const top = ignoredArgumentKeys(toolName, args, published);
1834
+ const nested = PASSTHROUGH_ARGUMENT_TOOLS.has(toolName) || published === null ? [] : await nestedIgnoredFor(toolName, args);
1835
+ const ignored = [...top, ...nested];
1658
1836
  if (!ignored.length)
1659
1837
  return null;
1660
- return { note: ignoredArgumentsNote(toolName, args, published), ignored };
1838
+ const topNote = top.length ? ignoredArgumentsNote(toolName, args, published) : '';
1839
+ const nestedNote = nested.length
1840
+ ? `IGNORED ARGUMENTS: ${nested.join(', ')} ${nested.length > 1 ? 'are' : 'is'} not a parameter of ${toolName}, so ${nested.length > 1 ? 'they had' : 'it had'} no effect.`
1841
+ : '';
1842
+ return { note: [topNote, nestedNote].filter(Boolean).join(' '), ignored };
1843
+ }
1844
+ async function nestedIgnoredFor(toolName, args) {
1845
+ const { PINEPAPER_TOOLS } = await import('./definitions.js');
1846
+ const tool = PINEPAPER_TOOLS.find((t) => t.name === toolName);
1847
+ if (!tool)
1848
+ return [];
1849
+ const hidden = new Set(NESTED_HIDDEN[toolName] ?? []);
1850
+ return nestedIgnoredPaths(tool.inputSchema, args).filter((p) => !hidden.has(p.replace(/\[\d+\]/g, '[]')));
1661
1851
  }
1662
1852
  export const IGNORED_ARGUMENTS_META_KEY = 'pinepaper.studio/ignoredArguments';
1663
1853
  export async function handleToolCall(toolName, args, options = {}) {
@@ -1798,6 +1988,11 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1798
1988
  const code = codeGenerator.generateStagger(input);
1799
1989
  return executeOrGenerate(code, `Stagger: ${input.action}`, options, 'pinepaper_stagger');
1800
1990
  }
1991
+ case 'pinepaper_pattern': {
1992
+ const input = PatternInputSchema.parse(args);
1993
+ const code = codeGenerator.generatePattern(input);
1994
+ return executeOrGenerate(code, `Pattern: ${input.action}${input.id ? ' ' + input.id : ''}`, options, 'pinepaper_pattern');
1995
+ }
1801
1996
  case 'pinepaper_flip': {
1802
1997
  const input = FlipInputSchema.parse(args);
1803
1998
  const code = codeGenerator.generateFlip(input);
@@ -3611,8 +3806,26 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3611
3806
  })));
3612
3807
  }
3613
3808
  }
3809
+ let expanded;
3810
+ try {
3811
+ expanded = expandBatchMacros(input.operations);
3812
+ }
3813
+ catch (e) {
3814
+ if (e instanceof BatchMacroError)
3815
+ return errorResult(ErrorCodes.VALIDATION_ERROR, e.message, {}, { toolName: 'pinepaper_agent_batch_execute' });
3816
+ throw e;
3817
+ }
3818
+ // A timeline op compiles on the server: a tween it cannot honour is refused by name here, before the canvas is
3819
+ // touched (skipValidation does not bypass it — there is nothing to send for a tween that did not compile).
3820
+ if (expanded.some((op) => op.type === 'timeline')) {
3821
+ const tl = compileBatchTimelines(expanded);
3822
+ if (tl.errors.length > 0) {
3823
+ 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 })));
3824
+ }
3825
+ }
3614
3826
  const code = codeGenerator.generateAgentBatchExecute(input);
3615
- const description = `Batch executed ${input.operations.length} operations${input.atomic !== false ? ' (atomic)' : ''}`;
3827
+ const description = `Batch executed ${input.operations.length} operations${input.atomic !== false ? ' (atomic)' : ''}`
3828
+ + (expanded.length !== input.operations.length ? ` (${expanded.length} after expanding ring / grid / orbit / stagger)` : '');
3616
3829
  // Check for out-of-bounds items before execution
3617
3830
  let boundsWarning = '';
3618
3831
  const sizeOp = input.operations.find((op) => op.type === 'set_canvas_size');
@@ -3620,7 +3833,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3620
3833
  const cw = sizeOp.width || 1920;
3621
3834
  const ch = sizeOp.height || 1080;
3622
3835
  const oobItems = [];
3623
- input.operations.forEach((op, idx) => {
3836
+ expanded.forEach((op, idx) => {
3624
3837
  if (op.type === 'create' && op.position) {
3625
3838
  const { x, y } = op.position;
3626
3839
  if (x < 0 || y < 0 || x > cw || y > ch) {
@@ -3632,7 +3845,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3632
3845
  boundsWarning = `\n\nWARNING: ${oobItems.length} item(s) positioned outside canvas bounds (${cw}x${ch}): ${oobItems.join(', ')}. Items may be clipped or invisible.`;
3633
3846
  }
3634
3847
  }
3635
- const batchResult = await executeOrGenerate(code, description, options, 'pinepaper_agent_batch_execute');
3848
+ const batchResult = await executeOrGenerate(code, description, input.atomic !== false ? { ...options, rollbackCreatedOnFailure: true } : options, 'pinepaper_agent_batch_execute');
3636
3849
  // Append bounds warning if any
3637
3850
  if (boundsWarning && batchResult.content && batchResult.content.length > 0) {
3638
3851
  const firstContent = batchResult.content[0];
@@ -3804,13 +4017,14 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3804
4017
  format,
3805
4018
  chunkBytes: Number(exportResult.chunkBytes) || undefined,
3806
4019
  }, input.platform || 'auto');
3807
- const cleanResult = { ...exportResult, data: undefined, filePath, fileSize, chunks };
3808
- return {
4020
+ const receipt = await exportFileReceipt(filePath, exportResult);
4021
+ const cleanResult = { ...exportResult, data: undefined, filePath, fileSize, chunks, file: receipt };
4022
+ return withEngineReport({
3809
4023
  content: [{
3810
4024
  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)}`,
4025
+ 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${probeLines(receipt)}${lintLines(exportResult)}\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
3812
4026
  }],
3813
- };
4027
+ }, exportBrowserResult.report);
3814
4028
  }
3815
4029
  catch (streamError) {
3816
4030
  // The export is still held — the message says so and names the id,
@@ -3824,7 +4038,14 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3824
4038
  // SCC (CEA-608, 8.37): the studio sent the cues; encoded here.
3825
4039
  if (format === 'scc' && Array.isArray(exportResult?.sccCues)) {
3826
4040
  const scc = buildScc(exportResult.sccCues);
3827
- const { filePath, fileSize } = await saveExportToFile(scc.text, 'scc', input.platform || 'auto');
4041
+ let saved;
4042
+ try {
4043
+ saved = await saveExportToFile(scc.text, 'scc', input.platform || 'auto');
4044
+ }
4045
+ catch (saveError) {
4046
+ return exportStepErrorResult(saveError, 'save', { format: 'scc' });
4047
+ }
4048
+ const { filePath, fileSize } = saved;
3828
4049
  const warnings = [];
3829
4050
  if (scc.truncated)
3830
4051
  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 +4056,22 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3835
4056
  const { sccCues: _c, ...rest } = exportResult;
3836
4057
  void _c;
3837
4058
  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)}` }] };
4059
+ 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)}` }] }, exportBrowserResult.report);
3839
4060
  }
3840
4061
  if ((format === 'html5-ad' || format === 'playable') && typeof exportResult?.adPage === 'string') {
3841
- return saveAdExport(exportResult, input, input.platform || 'auto');
4062
+ try {
4063
+ return withEngineReport(await saveAdExport(exportResult, input, input.platform || 'auto'), exportBrowserResult.report);
4064
+ }
4065
+ catch (saveError) {
4066
+ return exportStepErrorResult(saveError, 'save', { format });
4067
+ }
4068
+ }
4069
+ // NO SUCCESS WITHOUT BYTES. Every delivering branch above returned; what is left must carry the file's
4070
+ // data. A success with none (no data field, an empty string) used to fall through to the inline return and
4071
+ // report success with nothing in it. An estimate is the one answer that is not a file.
4072
+ if (!exportResult?.estimateOnly && !(typeof data === 'string' && data.length > 0)) {
4073
+ 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}`})`);
4074
+ return errorResult(ErrorCodes.EXECUTION_ERROR, err.message, { ...err.toData(), format, result: exportResult }, { toolName: 'pinepaper_agent_export' });
3842
4075
  }
3843
4076
  const shouldSaveToFile = data && typeof data === 'string' && (options.saveToFile ||
3844
4077
  ALWAYS_SAVE_FORMATS.has(format) ||
@@ -3846,29 +4079,32 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
3846
4079
  if (shouldSaveToFile) {
3847
4080
  try {
3848
4081
  const { filePath, fileSize } = await saveExportToFile(data, format, input.platform || 'auto');
3849
- const cleanResult = { ...exportResult, data: undefined, filePath, fileSize };
4082
+ const receipt = await exportFileReceipt(filePath, exportResult);
4083
+ const cleanResult = { ...exportResult, data: undefined, filePath, fileSize, file: receipt };
3850
4084
  // A mix that dropped sounds wrote a perfectly good file that is
3851
4085
  // missing tracks. It rides the SUCCESS path, which is exactly why
3852
4086
  // it has to be said out loud here rather than left in the JSON.
3853
4087
  const dropNote = exportResult.incomplete;
3854
- return {
4088
+ return withEngineReport({
3855
4089
  content: [{
3856
4090
  type: 'text',
3857
4091
  text: `Export saved to file:\n\nFile: ${filePath}\nFormat: ${format}\nSize: ${(fileSize / 1024).toFixed(1)} KB\nPlatform: ${input.platform}\n`
3858
4092
  + (dropNote ? `DROPPED: ${dropNote}\n` : '')
4093
+ + probeLines(receipt)
4094
+ + lintLines(exportResult)
3859
4095
  + `\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
3860
4096
  }],
3861
- };
4097
+ }, exportBrowserResult.report);
3862
4098
  }
3863
4099
  catch (saveError) {
4100
+ // A failed save is a failed export, never a success with a note inside it: the step, the path and
4101
+ // the reason, and the data stripped so the error is not the size of the video.
3864
4102
  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);
4103
+ return exportStepErrorResult(saveError, 'save', { format, bytes: typeof data === 'string' ? data.length : 0 });
3868
4104
  }
3869
4105
  }
3870
- // Small export — return inline (existing behavior)
3871
- return executedResult(code, exportResult, exportBrowserResult.screenshot, description);
4106
+ // Small export — return inline (existing behavior). The bytes are in the result, checked non-empty above.
4107
+ return executedResult(code, exportResult, exportBrowserResult.screenshot, description, exportBrowserResult.report);
3872
4108
  }
3873
4109
  case 'pinepaper_design_styles':
3874
4110
  return dataResult({ styles: designSystems.listStyles() });
@@ -4039,11 +4275,8 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
4039
4275
  };
4040
4276
  }
4041
4277
  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);
4278
+ // The soundtrack rendered; writing it did not. That is a failed export, said as one.
4279
+ return exportStepErrorResult(saveError, 'save', { format: 'wav', byteLength: soundtrack.byteLength }, 'pinepaper_sound');
4047
4280
  }
4048
4281
  }
4049
4282
  return executeOrGenerate(code, soundDescription, options, 'pinepaper_sound');
@@ -4292,7 +4525,7 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
4292
4525
  }
4293
4526
  catch (error) {
4294
4527
  if (error instanceof ZodError) {
4295
- return handleValidationError(error, i18n);
4528
+ return handleValidationError(error, i18n, args);
4296
4529
  }
4297
4530
  // Try to capture canvas state for error context (if browser is connected)
4298
4531
  let canvasState = null;
@@ -4305,6 +4538,10 @@ You can now start creating new items on a clean canvas.${sizeNote}`,
4305
4538
  catch (_stateError) {
4306
4539
  // Ignore errors from canvas state capture
4307
4540
  }
4541
+ // An export that failed at a named step keeps its step, path and reason as fields.
4542
+ if (error instanceof ExportStepError) {
4543
+ return errorResult(ErrorCodes.EXECUTION_ERROR, error.message, error.toData(), { toolName, canvasState: canvasState || undefined });
4544
+ }
4308
4545
  if (error instanceof Error) {
4309
4546
  const message = i18n
4310
4547
  ? i18n.getError('executionError', { message: error.message })