@engine-room/after-effects-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/server.js CHANGED
@@ -9,12 +9,138 @@ var __export = (target, all) => {
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
 
11
11
  // src/cli/init.ts
12
- import path2 from "node:path";
12
+ import path3 from "node:path";
13
13
 
14
14
  // src/setup/scaffold.ts
15
+ import fs2 from "node:fs";
16
+ import os2 from "node:os";
17
+ import path2 from "node:path";
18
+
19
+ // src/setup/paths.ts
15
20
  import fs from "node:fs";
16
21
  import os from "node:os";
17
22
  import path from "node:path";
23
+ import { createRequire } from "node:module";
24
+ import { fileURLToPath } from "node:url";
25
+ var BUNDLE_ID = "games.engine-room.ae-mcp";
26
+ function isSupportedPlatform() {
27
+ return process.platform === "darwin" || process.platform === "win32";
28
+ }
29
+ function packageRoot() {
30
+ let dir = path.dirname(fileURLToPath(import.meta.url));
31
+ for (let i = 0; i < 8; i++) {
32
+ if (fs.existsSync(path.join(dir, "package.json"))) return dir;
33
+ const parent = path.dirname(dir);
34
+ if (parent === dir) break;
35
+ dir = parent;
36
+ }
37
+ return path.dirname(fileURLToPath(import.meta.url));
38
+ }
39
+ function executableDir() {
40
+ return path.dirname(process.execPath);
41
+ }
42
+ function packageVersion() {
43
+ for (const dir of [packageRoot(), executableDir()]) {
44
+ try {
45
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
46
+ if (typeof pkg.version === "string") return pkg.version;
47
+ } catch {
48
+ }
49
+ }
50
+ return "unknown";
51
+ }
52
+ function panelSourceDir() {
53
+ const candidates = [
54
+ // The live workspace copy comes first so a git checkout always installs
55
+ // what the developer is editing, never a stale vendored copy left behind by
56
+ // a previous `npm pack`. Only the second path exists in the tarball.
57
+ path.resolve(packageRoot(), "..", "ae-panel"),
58
+ path.join(packageRoot(), "panel"),
59
+ // Compiled single-file build: the panel ships beside the executable.
60
+ path.join(executableDir(), "panel")
61
+ ];
62
+ for (const dir of candidates) {
63
+ if (fs.existsSync(path.join(dir, "CSXS", "manifest.xml"))) return dir;
64
+ }
65
+ return null;
66
+ }
67
+ function cepExtensionsDir() {
68
+ if (process.platform === "win32") {
69
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
70
+ return path.join(appData, "Adobe", "CEP", "extensions");
71
+ }
72
+ return path.join(os.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
73
+ }
74
+ function installedPanelDir() {
75
+ return path.join(cepExtensionsDir(), BUNDLE_ID);
76
+ }
77
+ function signedInstallPresent(dir) {
78
+ return fs.existsSync(path.join(dir, "META-INF", "signatures.xml")) || fs.existsSync(path.join(dir, "META-INF")) || fs.existsSync(path.join(dir, "mimetype"));
79
+ }
80
+ function isWsModuleDir(dir) {
81
+ try {
82
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
83
+ if (pkg.name !== "ws") return false;
84
+ return fs.existsSync(path.join(dir, "index.js")) && fs.existsSync(path.join(dir, "lib", "websocket.js"));
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+ function wsModuleDir() {
90
+ const candidates = [];
91
+ try {
92
+ const require2 = createRequire(import.meta.url);
93
+ const entry = require2.resolve("ws");
94
+ if (path.isAbsolute(entry)) {
95
+ const marker = `${path.sep}node_modules${path.sep}ws${path.sep}`;
96
+ const idx = entry.lastIndexOf(marker);
97
+ candidates.push(idx >= 0 ? entry.slice(0, idx + marker.length - 1) : path.dirname(entry));
98
+ }
99
+ } catch {
100
+ }
101
+ candidates.push(path.join(executableDir(), "node_modules", "ws"));
102
+ candidates.push(path.join(packageRoot(), "node_modules", "ws"));
103
+ for (const dir of candidates) {
104
+ if (isWsModuleDir(dir)) return dir;
105
+ }
106
+ return null;
107
+ }
108
+ function listPanelFiles(dir, prefix = "") {
109
+ const out = [];
110
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
111
+ if (entry.name === "node_modules" || entry.name === ".DS_Store") continue;
112
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
113
+ if (entry.isDirectory()) out.push(...listPanelFiles(path.join(dir, entry.name), rel));
114
+ else out.push(rel);
115
+ }
116
+ return out;
117
+ }
118
+ function sameContents(a, b) {
119
+ try {
120
+ if (fs.statSync(a).size !== fs.statSync(b).size) return false;
121
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+ function panelInstallDiff(source, installed) {
127
+ return listPanelFiles(source).filter((rel) => !sameContents(path.join(source, rel), path.join(installed, rel))).sort();
128
+ }
129
+ function copyRecursive(src, dst) {
130
+ const stat = fs.lstatSync(src);
131
+ if (stat.isDirectory()) {
132
+ fs.mkdirSync(dst, { recursive: true });
133
+ for (const entry of fs.readdirSync(src)) {
134
+ copyRecursive(path.join(src, entry), path.join(dst, entry));
135
+ }
136
+ } else if (stat.isSymbolicLink()) {
137
+ fs.symlinkSync(fs.readlinkSync(src), dst);
138
+ } else {
139
+ fs.copyFileSync(src, dst);
140
+ }
141
+ }
142
+
143
+ // src/setup/scaffold.ts
18
144
  var ScaffoldError = class extends Error {
19
145
  };
20
146
  function detectClient(clientName) {
@@ -38,10 +164,10 @@ function mcpConfigFor(client) {
38
164
  case "claude-code":
39
165
  return { rel: ".mcp.json", json: standard };
40
166
  case "cursor":
41
- return { rel: path.join(".cursor", "mcp.json"), json: standard };
167
+ return { rel: path2.join(".cursor", "mcp.json"), json: standard };
42
168
  case "vscode":
43
169
  return {
44
- rel: path.join(".vscode", "mcp.json"),
170
+ rel: path2.join(".vscode", "mcp.json"),
45
171
  json: JSON.stringify({ servers: { "after-effects": { type: "stdio", ...MCP_SERVER_ENTRY } } }, null, 2) + "\n"
46
172
  };
47
173
  default:
@@ -50,25 +176,30 @@ function mcpConfigFor(client) {
50
176
  }
51
177
  function globalConfigHint(client) {
52
178
  const standard = JSON.stringify({ mcpServers: { "after-effects": MCP_SERVER_ENTRY } }, null, 2) + "\n";
53
- const home = os.homedir();
179
+ const home = os2.homedir();
54
180
  switch (client) {
55
181
  case "claude-desktop":
56
182
  return {
57
- path: process.platform === "darwin" ? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json"),
183
+ path: process.platform === "darwin" ? path2.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : path2.join(process.env.APPDATA ?? path2.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json"),
58
184
  json: standard
59
185
  };
60
186
  case "windsurf":
61
- return { path: path.join(home, ".codeium", "windsurf", "mcp_config.json"), json: standard };
187
+ return { path: path2.join(home, ".codeium", "windsurf", "mcp_config.json"), json: standard };
62
188
  case "codex":
63
189
  return {
64
- path: path.join(home, ".codex", "config.toml"),
190
+ path: path2.join(home, ".codex", "config.toml"),
65
191
  json: '[mcp_servers.after-effects]\ncommand = "npx"\nargs = ["-y", "@engine-room/after-effects-mcp"]\n'
66
192
  };
67
193
  default:
68
194
  return void 0;
69
195
  }
70
196
  }
71
- var AGENTS_MD = (name) => `# ${name}
197
+ var ABSORBED_MARKER_PREFIX = "Tools version last absorbed:";
198
+ var ABSORBED_MARKER = new RegExp(`^\\s*${ABSORBED_MARKER_PREFIX}\\s*v?(\\d+\\.\\d+\\.\\d+)\\s*$`, "m");
199
+ function absorbedMarkerLine(version) {
200
+ return `${ABSORBED_MARKER_PREFIX} ${version}`;
201
+ }
202
+ var AGENTS_MD = (name, version) => `# ${name}
72
203
 
73
204
  An After Effects project folder. The AE tools drive After Effects directly from
74
205
  here \u2014 describe what you want and it gets built in the open project.
@@ -94,6 +225,14 @@ Check \`list_known_issues\` before guessing \u2014 an earlier session may alread
94
225
  solved it. Anything newly worked out goes in with \`log_issue\`, and the
95
226
  report-ae-issue prompt sends it to the maintainers.
96
227
 
228
+ ## Tool updates
229
+
230
+ ${absorbedMarkerLine(version)}
231
+
232
+ When the After Effects tools update, the absorb-release prompt reads what
233
+ changed since the version above, fixes any rule in this folder the update made
234
+ stale, and moves the version on. Leave the line where it is.
235
+
97
236
  ## Conventions for this project
98
237
 
99
238
  <!-- Anything specific to this project rather than to your general style: naming
@@ -123,48 +262,51 @@ function pointerFiles(client) {
123
262
  case "claude-code":
124
263
  return ["CLAUDE.md"];
125
264
  case "cursor":
126
- return [path.join(".cursor", "rules", "after-effects.mdc")];
265
+ return [path2.join(".cursor", "rules", "after-effects.mdc")];
127
266
  case "windsurf":
128
267
  return [".windsurfrules"];
129
268
  case "vscode":
130
- return [path.join(".github", "copilot-instructions.md")];
269
+ return [path2.join(".github", "copilot-instructions.md")];
131
270
  default:
132
271
  return [];
133
272
  }
134
273
  }
135
274
  function resolveTarget(dir, roots) {
136
275
  if (dir && dir.trim().length > 0) {
137
- return { dir: path.resolve(dir.trim()), resolvedFrom: "argument" };
276
+ return refuseUnscoped({ dir: path2.resolve(dir.trim()), resolvedFrom: "argument" });
138
277
  }
139
278
  const root = roots?.find((r) => r && r.trim().length > 0);
140
- if (root) return { dir: path.resolve(root), resolvedFrom: "client-root" };
279
+ if (root) return refuseUnscoped({ dir: path2.resolve(root), resolvedFrom: "client-root" });
141
280
  const cwd = process.cwd();
142
- const isFilesystemRoot = cwd === path.parse(cwd).root;
143
- if (isFilesystemRoot || cwd === os.homedir()) {
144
- throw new ScaffoldError(
145
- `No project folder to write to. This client did not say which folder it is working in, and the server was started in ${cwd}, which is not somewhere a project should be created. Ask the user which folder they want the project in \u2014 a new one is fine \u2014 and pass it as \`dir\`.`
146
- );
147
- }
148
- return { dir: cwd, resolvedFrom: "working-directory" };
281
+ return refuseUnscoped({ dir: cwd, resolvedFrom: "working-directory" });
282
+ }
283
+ function refuseUnscoped(target) {
284
+ const { dir, resolvedFrom } = target;
285
+ const isFilesystemRoot = dir === path2.parse(dir).root;
286
+ if (!isFilesystemRoot && dir !== os2.homedir()) return target;
287
+ const how = resolvedFrom === "argument" ? `\`dir\` is ${dir}` : resolvedFrom === "client-root" ? `the client says it is working in ${dir}` : `this client did not say which folder it is working in, and the server was started in ${dir}`;
288
+ throw new ScaffoldError(
289
+ `No project folder to write to: ${how}, which is not somewhere a project should be created. Ask the user which folder they want the project in \u2014 a new one is fine \u2014 and pass it as \`dir\`.`
290
+ );
149
291
  }
150
292
  function scaffold(opts) {
151
293
  const { dir, resolvedFrom } = resolveTarget(opts.dir, opts.roots);
152
- const name = opts.name?.trim() || path.basename(dir);
153
- const files = [["AGENTS.md", AGENTS_MD(name)]];
294
+ const name = opts.name?.trim() || path2.basename(dir);
295
+ const files = [["AGENTS.md", AGENTS_MD(name, packageVersion())]];
154
296
  for (const rel of pointerFiles(opts.client)) files.push([rel, pointerContent(rel)]);
155
- files.push([path.join("renders", ".gitkeep"), ""]);
297
+ files.push([path2.join("renders", ".gitkeep"), ""]);
156
298
  const projectConfig = opts.withMcpConfig ? mcpConfigFor(opts.client) : void 0;
157
299
  if (projectConfig) files.push([projectConfig.rel, projectConfig.json]);
158
- const existing = files.map(([rel]) => rel).filter((rel) => fs.existsSync(path.join(dir, rel)));
300
+ const existing = files.map(([rel]) => rel).filter((rel) => fs2.existsSync(path2.join(dir, rel)));
159
301
  if (existing.length > 0) {
160
302
  throw new ScaffoldError(
161
303
  `${dir} already has ${existing.join(", ")}. Nothing was written. This folder is already set up \u2014 or pick a different one.`
162
304
  );
163
305
  }
164
306
  for (const [rel, content] of files) {
165
- const full = path.join(dir, rel);
166
- fs.mkdirSync(path.dirname(full), { recursive: true });
167
- fs.writeFileSync(full, content, "utf8");
307
+ const full = path2.join(dir, rel);
308
+ fs2.mkdirSync(path2.dirname(full), { recursive: true });
309
+ fs2.writeFileSync(full, content, "utf8");
168
310
  }
169
311
  const hint = opts.withMcpConfig && !projectConfig ? globalConfigHint(opts.client) : void 0;
170
312
  const nextSteps = [
@@ -246,7 +388,7 @@ Usage: npx @engine-room/after-effects-mcp init <directory> [--no-mcp] [--client
246
388
  }
247
389
  const lines = [`Created ${result.dir}`, ``];
248
390
  for (const rel of result.written) {
249
- if (rel.endsWith(".gitkeep")) lines.push(` ${pad(path2.dirname(rel) + "/")} exports land here`);
391
+ if (rel.endsWith(".gitkeep")) lines.push(` ${pad(path3.dirname(rel) + "/")} exports land here`);
250
392
  else if (rel === "AGENTS.md") lines.push(` ${pad(rel)} what this project is`);
251
393
  else if (rel.endsWith("mcp.json")) lines.push(` ${pad(rel)} connects your client to After Effects`);
252
394
  else lines.push(` ${pad(rel)} points your client at AGENTS.md`);
@@ -263,16 +405,300 @@ Usage: npx @engine-room/after-effects-mcp init <directory> [--no-mcp] [--client
263
405
  result.mcpConfigHint.json.trimEnd()
264
406
  );
265
407
  }
266
- lines.push(``);
267
- process.stdout.write(lines.join("\n"));
268
- return 0;
408
+ lines.push(``);
409
+ process.stdout.write(lines.join("\n"));
410
+ return 0;
411
+ }
412
+ function pad(s) {
413
+ return s.padEnd(38);
414
+ }
415
+
416
+ // src/server.ts
417
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
418
+
419
+ // src/bridge/discovery.ts
420
+ import fs3 from "node:fs";
421
+ import os3 from "node:os";
422
+ import path4 from "node:path";
423
+
424
+ // src/util/errors.ts
425
+ var BridgeUnreachableError = class _BridgeUnreachableError extends Error {
426
+ constructor(port, cause) {
427
+ super(_BridgeUnreachableError.message(port, cause));
428
+ this.port = port;
429
+ this.name = "BridgeUnreachableError";
430
+ }
431
+ port;
432
+ static message(port, cause) {
433
+ return [
434
+ `Cannot reach the After Effects panel at http://127.0.0.1:${port}.`,
435
+ cause ? `Underlying error: ${cause.message}` : "",
436
+ "",
437
+ "Call the check_setup tool to find out why, then relay its nextSteps to the user.",
438
+ "In most cases the fix is either running setup_panel (installs the panel) or",
439
+ "simply opening After Effects."
440
+ ].filter(Boolean).join("\n");
441
+ }
442
+ };
443
+ var BridgeTimeoutError = class _BridgeTimeoutError extends Error {
444
+ constructor(port, timeoutMs, opts = {}) {
445
+ super(_BridgeTimeoutError.message(port, timeoutMs, opts));
446
+ this.port = port;
447
+ this.timeoutMs = timeoutMs;
448
+ this.name = "BridgeTimeoutError";
449
+ }
450
+ port;
451
+ timeoutMs;
452
+ static message(port, timeoutMs, opts = {}) {
453
+ const secs = Math.round(timeoutMs / 1e3);
454
+ const what = opts.op ? `\`${opts.op}\`` : "the call";
455
+ return [
456
+ `The After Effects panel at http://127.0.0.1:${port} did not answer within ${secs}s for ${what}.`,
457
+ "",
458
+ "This is a timeout, not a lost connection \u2014 the panel is very probably still",
459
+ "there. ExtendScript is single-threaded, so while After Effects is busy running a",
460
+ "script it cannot answer the bridge at all, and a busy AE looks exactly like a",
461
+ "dead one from here. It usually recovers on its own.",
462
+ "",
463
+ "What to do, in order:",
464
+ "1. Do not restart After Effects, and do not run setup_panel. Do not re-send the",
465
+ " call either \u2014 that would queue a second copy of the same work.",
466
+ "2. Call check_setup, and keep polling it for about a minute. The bridge normally",
467
+ " comes back by itself once the script finishes.",
468
+ "3. Ask the user to look at After Effects. A modal dialog \u2014 an unsaved-project",
469
+ " prompt, a missing-font warning \u2014 blocks it in the same way and may be hidden",
470
+ " behind another window. Only they can click it.",
471
+ "4. On macOS, if they have switched to another desktop (Space), ask them to switch",
472
+ " back to the one After Effects is on. Calls have been reported to stall until",
473
+ " they return, then complete normally.",
474
+ "5. Only if check_setup still reports the bridge down after a minute should you",
475
+ " treat this as a real disconnection and follow its nextSteps.",
476
+ opts.adjustable === false ? "" : `
477
+ If this work legitimately takes longer than ${secs}s, the limit is settable: start the
478
+ server with AE_MCP_OP_TIMEOUT_MS set to a larger number of milliseconds.`
479
+ ].filter(Boolean).join("\n");
480
+ }
481
+ };
482
+ function isTimeoutError(e, depth = 0) {
483
+ if (!e || typeof e !== "object" || depth > 4) return false;
484
+ const name = e.name;
485
+ if (name === "TimeoutError" || name === "AbortError") return true;
486
+ const cause = e.cause;
487
+ return cause === e ? false : isTimeoutError(cause, depth + 1);
488
+ }
489
+ var AeError = class extends Error {
490
+ /**
491
+ * `code` is set only when the panel diagnosed the failure itself rather than
492
+ * relaying one from ExtendScript — `STALE_FRAME` is the first. Those messages
493
+ * already read as complete instructions, so the caller uses this to decide
494
+ * whether an `AE:` prefix would help or just obscure them.
495
+ */
496
+ constructor(message, stack_, line, code, source) {
497
+ super(message);
498
+ this.stack_ = stack_;
499
+ this.line = line;
500
+ this.code = code;
501
+ this.source = source;
502
+ this.name = "AeError";
503
+ }
504
+ stack_;
505
+ line;
506
+ code;
507
+ source;
508
+ };
509
+ function aeErrorText(e) {
510
+ const head = `AE: ${e.message}`;
511
+ const s = e.source;
512
+ if (!s) return e.line ? `${head} (line ${e.line})` : head;
513
+ const where = s.sourceName ?? "the script you submitted";
514
+ const total = s.lineCount ? `, ${s.lineCount} lines` : "";
515
+ const lines = [head];
516
+ if (s.sourceLine) {
517
+ lines.push(` at line ${s.sourceLine} of ${where}${total}:`);
518
+ if (s.sourceText) lines.push(` ${s.sourceText}`);
519
+ if (s.rawLine != null && s.rawLine !== s.sourceLine) {
520
+ lines.push(` (After Effects reported line ${s.rawLine}.)`);
521
+ }
522
+ } else if (s.rawLine != null) {
523
+ lines.push(
524
+ ` After Effects reported line ${s.rawLine}, which does not fall inside ${where}${total} \u2014 trust the message, not the number.`
525
+ );
526
+ }
527
+ lines.push(
528
+ " Everything before the failure already ran and nothing rolls back: read the state back rather than re-running the script."
529
+ );
530
+ return lines.join("\n");
531
+ }
532
+ var WriteQueueWaitError = class _WriteQueueWaitError extends Error {
533
+ constructor(op, waitedMs, behind) {
534
+ super(_WriteQueueWaitError.message(op, waitedMs, behind));
535
+ this.op = op;
536
+ this.waitedMs = waitedMs;
537
+ this.behind = behind;
538
+ this.name = "WriteQueueWaitError";
539
+ }
540
+ op;
541
+ waitedMs;
542
+ behind;
543
+ static message(op, waitedMs, behind) {
544
+ const secs = Math.round(waitedMs / 1e3);
545
+ return [
546
+ `\`${op}\` waited ${secs}s behind \`${behind}\` for the After Effects write queue and was dropped without running.`,
547
+ "",
548
+ "This is neither a lost connection nor a busy bridge. The panel is fine and this",
549
+ "call never reached After Effects, so nothing in the project was changed.",
550
+ "",
551
+ "Writes are serialized because After Effects runs one script at a time and applies",
552
+ "changes in the order it receives them; two in flight block each other and land in",
553
+ "an order nobody chose. Something in front of this call is taking a very long time",
554
+ "\u2014 most often a long run_batch, occasionally a modal dialog in After Effects",
555
+ "blocking the script in front.",
556
+ "",
557
+ "What to do, in order:",
558
+ "1. Nothing was written, so re-sending this call is safe \u2014 unlike a bridge timeout.",
559
+ " Wait for the work in front to finish first, or it will just queue again.",
560
+ "2. Find out what is in front: get_job or await_job for a batch. Reads are not",
561
+ " queued, so list_/get_ calls still work and will tell you the current state.",
562
+ "3. Ask the user to look at After Effects for a dialog nobody has clicked.",
563
+ `4. If the work in front is legitimately this long, raise the limit: start the`,
564
+ " server with AE_MCP_WRITE_QUEUE_WAIT_MS set to a larger number of milliseconds."
565
+ ].join("\n");
566
+ }
567
+ };
568
+ var WriteQueueFullError = class _WriteQueueFullError extends Error {
569
+ constructor(op, maxDepth, behind) {
570
+ super(_WriteQueueFullError.message(op, maxDepth, behind));
571
+ this.op = op;
572
+ this.maxDepth = maxDepth;
573
+ this.behind = behind;
574
+ this.name = "WriteQueueFullError";
575
+ }
576
+ op;
577
+ maxDepth;
578
+ behind;
579
+ static message(op, maxDepth, behind) {
580
+ return [
581
+ `The After Effects write queue is full \u2014 ${maxDepth} calls are already waiting, so \`${op}\` was refused.`,
582
+ "",
583
+ `Nothing was written. Writes are serialized so they land in the order you sent them,`,
584
+ `and \`${behind}\` is holding the queue up.`,
585
+ "",
586
+ "Stop issuing writes and let it drain \u2014 reads are not queued, so list_/get_ calls",
587
+ "still work. If you have this much independent work to do, send it as one",
588
+ "run_batch instead of as many calls: that is one ExtendScript pass and one place in",
589
+ "the queue, and its result reports how many undo steps it actually made.",
590
+ "AE_MCP_WRITE_QUEUE_DEPTH raises the limit if you really need it raised."
591
+ ].join("\n");
592
+ }
593
+ };
594
+ var WriteQueueCancelledError = class extends Error {
595
+ constructor(op) {
596
+ super(`\`${op}\` was cancelled while waiting for the After Effects write queue. It never ran, and nothing was changed.`);
597
+ this.op = op;
598
+ this.name = "WriteQueueCancelledError";
599
+ }
600
+ op;
601
+ };
602
+ function invalidArgsText(op, e) {
603
+ const issues = e?.issues;
604
+ if (!Array.isArray(issues) || issues.length === 0) {
605
+ return `Invalid arguments for ${op}: ${e.message}`;
606
+ }
607
+ const lines = issues.map((raw) => {
608
+ const issue = raw;
609
+ const where = Array.isArray(issue.path) && issue.path.length ? issue.path.join(".") : null;
610
+ let text = issue.message ?? "invalid";
611
+ if (issue.code === "invalid_type" && issue.received === "undefined") {
612
+ text = "required, and was not passed";
613
+ } else if (issue.code === "invalid_type" && issue.expected) {
614
+ text = `expected ${issue.expected}, got ${issue.received ?? "something else"}`;
615
+ }
616
+ return where ? ` - ${where}: ${text}` : ` - ${text}`;
617
+ });
618
+ const head = issues.length === 1 ? `Invalid arguments for ${op}:` : `Invalid arguments for ${op} (${issues.length} problems):`;
619
+ return [head, ...lines].join("\n");
620
+ }
621
+
622
+ // src/bridge/discovery.ts
623
+ var DEFAULT_PORT = 7777;
624
+ function portFilePath() {
625
+ return path4.join(os3.homedir(), ".engineroom-ae-mcp", "port");
626
+ }
627
+ function pinnedPort() {
628
+ const envPort = process.env.AE_MCP_PORT;
629
+ if (!envPort) return null;
630
+ const n = parseInt(envPort, 10);
631
+ return Number.isFinite(n) && n > 0 ? n : null;
632
+ }
633
+ function portFilePort() {
634
+ try {
635
+ const f = portFilePath();
636
+ if (fs3.existsSync(f)) {
637
+ const txt = fs3.readFileSync(f, "utf8").trim();
638
+ const n = parseInt(txt, 10);
639
+ if (Number.isFinite(n) && n > 0) return n;
640
+ }
641
+ } catch {
642
+ }
643
+ return null;
644
+ }
645
+ function discoverPort() {
646
+ return pinnedPort() ?? portFilePort() ?? DEFAULT_PORT;
647
+ }
648
+ function portCandidates() {
649
+ const pinned = pinnedPort();
650
+ if (pinned !== null) return [pinned];
651
+ const out = [DEFAULT_PORT];
652
+ const fromFile = portFilePort();
653
+ if (fromFile !== null && fromFile !== DEFAULT_PORT) out.push(fromFile);
654
+ return out;
655
+ }
656
+ function diagnosticPortCandidates() {
657
+ const out = [...portCandidates()];
658
+ for (const p of [DEFAULT_PORT, portFilePort()]) {
659
+ if (p !== null && !out.includes(p)) out.push(p);
660
+ }
661
+ return out;
662
+ }
663
+ function isPanelHealth(body) {
664
+ if (!body || typeof body !== "object") return false;
665
+ const b = body;
666
+ return b.ok === true && ("bundleHash" in b || "bundleLoaded" in b);
667
+ }
668
+ var PROBE_TIMEOUT_MS = 2e3;
669
+ async function probePanel(port, timeoutMs = PROBE_TIMEOUT_MS) {
670
+ try {
671
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(timeoutMs) });
672
+ if (!res.ok) {
673
+ return { port, status: "other", detail: `port ${port} answered HTTP ${res.status}, which is not the panel` };
674
+ }
675
+ const body = await res.json().catch(() => null);
676
+ if (isPanelHealth(body)) {
677
+ return { port, status: "panel", health: body, detail: `the panel is answering on port ${port}` };
678
+ }
679
+ return { port, status: "other", detail: `port ${port} answered, but not as the After Effects panel` };
680
+ } catch (e) {
681
+ if (isTimeoutError(e)) {
682
+ return {
683
+ port,
684
+ status: "busy",
685
+ detail: `port ${port} accepted the connection but had not answered after ${Math.round(timeoutMs / 1e3)}s \u2014 a panel may be there and busy`
686
+ };
687
+ }
688
+ return { port, status: "none", detail: `nothing is listening on port ${port} (${e.message})` };
689
+ }
269
690
  }
270
- function pad(s) {
271
- return s.padEnd(38);
691
+ async function locatePanel(candidates = portCandidates()) {
692
+ const probed = [];
693
+ for (const port of candidates) {
694
+ const r = await probePanel(port);
695
+ probed.push(r);
696
+ if (r.status === "panel") return { found: r, probed };
697
+ }
698
+ return { found: null, probed };
272
699
  }
273
700
 
274
701
  // src/server.ts
275
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
276
702
  import {
277
703
  CallToolRequestSchema,
278
704
  GetPromptRequestSchema,
@@ -344,6 +770,7 @@ __export(schemas_exports, {
344
770
  AddShapeContent: () => AddShapeContent,
345
771
  AddTextAnimator: () => AddTextAnimator,
346
772
  AeGuide: () => AeGuide,
773
+ ArchiveIssue: () => ArchiveIssue,
347
774
  AudioCue: () => AudioCue,
348
775
  AwaitJob: () => AwaitJob,
349
776
  CROSS_FIELD_RULE: () => CROSS_FIELD_RULE,
@@ -381,6 +808,7 @@ __export(schemas_exports, {
381
808
  ImportFootage: () => ImportFootage,
382
809
  InitProject: () => InitProject,
383
810
  Interpolation: () => Interpolation,
811
+ LAYER_SUMMARY_SECTIONS: () => LAYER_SUMMARY_SECTIONS,
384
812
  ListAvailableEffects: () => ListAvailableEffects,
385
813
  ListComps: () => ListComps,
386
814
  ListEffects: () => ListEffects,
@@ -393,6 +821,7 @@ __export(schemas_exports, {
393
821
  ParentLayer: () => ParentLayer,
394
822
  PlaceAudioCues: () => PlaceAudioCues,
395
823
  PropertyPath: () => PropertyPath,
824
+ PurgeUnusedFootage: () => PurgeUnusedFootage,
396
825
  RemoveEffect: () => RemoveEffect,
397
826
  RemoveKeyframe: () => RemoveKeyframe,
398
827
  RemoveMarker: () => RemoveMarker,
@@ -513,33 +942,33 @@ function crossFieldRuleOf(node) {
513
942
  var ARRAY_ELEMENT = "[]";
514
943
  function crossFieldRulesIn(schema) {
515
944
  const found = [];
516
- const walk = (node, path10, depth) => {
945
+ const walk = (node, path12, depth) => {
517
946
  const def = node?._def;
518
947
  if (!def || depth > 16)
519
948
  return;
520
949
  switch (def.typeName) {
521
950
  case "ZodEffects":
522
- found.push({ path: [...path10], rule: crossFieldRuleOf(node) });
523
- return walk(def.schema, path10, depth + 1);
951
+ found.push({ path: [...path12], rule: crossFieldRuleOf(node) });
952
+ return walk(def.schema, path12, depth + 1);
524
953
  case "ZodOptional":
525
954
  case "ZodDefault":
526
955
  case "ZodNullable":
527
956
  case "ZodReadonly":
528
- return walk(def.innerType, path10, depth + 1);
957
+ return walk(def.innerType, path12, depth + 1);
529
958
  case "ZodObject": {
530
959
  const shape = def.shape();
531
960
  for (const key of Object.keys(shape))
532
- walk(shape[key], [...path10, key], depth + 1);
961
+ walk(shape[key], [...path12, key], depth + 1);
533
962
  return;
534
963
  }
535
964
  case "ZodArray":
536
- return walk(def.type, [...path10, ARRAY_ELEMENT], depth + 1);
965
+ return walk(def.type, [...path12, ARRAY_ELEMENT], depth + 1);
537
966
  case "ZodUnion":
538
967
  case "ZodDiscriminatedUnion": {
539
968
  const options = def.options;
540
969
  const list = Array.isArray(options) ? options : [...options.values()];
541
970
  for (const opt of list)
542
- walk(opt, path10, depth + 1);
971
+ walk(opt, path12, depth + 1);
543
972
  return;
544
973
  }
545
974
  default:
@@ -582,7 +1011,10 @@ var SetComp = z2.object({
582
1011
  workAreaDuration: z2.number().positive().optional(),
583
1012
  bgColor: Color.optional()
584
1013
  });
585
- var DeleteComp = z2.object({ compId: z2.number() });
1014
+ var DeleteComp = z2.object({
1015
+ compId: z2.number(),
1016
+ purgeUnusedSolids: z2.boolean().default(false).optional().describe("Also remove the solid footage items this comp's own layers used, once nothing else uses them. Off by default because a solid can be shared: one another comp still uses is kept and reported with the comps using it.")
1017
+ }).strict();
586
1018
  var SetActiveComp = z2.object({ compId: z2.number() });
587
1019
  var DuplicateComp = z2.object({
588
1020
  compId: z2.number(),
@@ -600,9 +1032,10 @@ var DiffComp = z2.object({
600
1032
  compId: z2.number().optional().describe("Defaults to the comp the snapshot was taken of; pass it only to assert which comp you mean."),
601
1033
  includeFingerprint: z2.boolean().default(false).optional().describe("Return the new fingerprint as well as the diff. Off by default.")
602
1034
  }).strict();
1035
+ var LAYER_SUMMARY_SECTIONS = ["flags", "timing", "parent"];
603
1036
  var ListLayers = z2.object({
604
1037
  compId: z2.number(),
605
- include: includeParam(["flags", "timing", "parent"], "the id/index/name/type map alone")
1038
+ include: includeParam(LAYER_SUMMARY_SECTIONS, "the id/index/name/type map alone")
606
1039
  });
607
1040
  var GetLayerFull = z2.object({
608
1041
  compId: z2.number(),
@@ -1025,7 +1458,12 @@ var FindLayers = z2.object({
1025
1458
  compId: z2.number().optional(),
1026
1459
  namePattern: z2.string().optional(),
1027
1460
  type: z2.string().optional(),
1028
- hasEffectMatchName: z2.string().optional()
1461
+ hasEffectMatchName: z2.string().optional(),
1462
+ // Same section names as list_layers, opposite default: a search is for
1463
+ // learning which layers exist and what to address them by, so omitting this
1464
+ // means the core alone rather than everything (issue #87). `includeParam`
1465
+ // is not reused because its sentence says "omit for all of them".
1466
+ include: z2.array(z2.enum(LAYER_SUMMARY_SECTIONS)).optional().describe(`Sections to add to each match: ${LAYER_SUMMARY_SECTIONS.join(", ")} \u2014 the same names as list_layers. Omit it (or pass []) for id/index/name/sourceType plus compId/compName only; unlike list_layers, omitting it here does NOT return every section. The result echoes what was included.`)
1029
1467
  });
1030
1468
  var RunJsx = crossField(
1031
1469
  z2.object({
@@ -1060,6 +1498,11 @@ var CreateFootageLayer = z2.object({
1060
1498
  position: VecAny.optional(),
1061
1499
  startTime: z2.number().optional()
1062
1500
  });
1501
+ var PurgeUnusedFootage = z2.object({
1502
+ solidsOnly: z2.boolean().default(true).optional().describe("Only remove solids \u2014 the items behind solid and adjustment layers. Pass false to remove every footage item no comp uses, imported files and placeholders included, which is what AE's own Remove Unused Footage does. Comps and folders are never removed either way."),
1503
+ dryRun: z2.boolean().default(false).optional().describe("List what would be removed without removing anything \u2014 not even an undo step."),
1504
+ folderId: z2.number().optional().describe("Restrict the sweep to one project folder and everything nested under it. A folder id from get_project_summary; omit for the whole project.")
1505
+ }).strict();
1063
1506
  var AudioCue = z2.object({
1064
1507
  footageId: z2.number().optional().describe("Project item id of an already-imported sound. Give this or `path`, not both."),
1065
1508
  path: z2.string().min(1).optional().describe("Absolute path to a sound file. Imported once per call however many cues name it, and an item already in the project from that path is reused rather than imported again."),
@@ -1068,13 +1511,18 @@ var AudioCue = z2.object({
1068
1511
  name: z2.string().optional().describe("Layer name. Defaults to namePrefix + the file's basename without its extension."),
1069
1512
  inPoint: z2.number().optional().describe("Trim the layer's in point to this COMP time. Must not be earlier than `time`. Omit to play from the start of the file."),
1070
1513
  outPoint: z2.number().optional().describe("Trim the layer's out point to this COMP time. Omit to play to the end of the file."),
1071
- label: z2.union([z2.number().int().min(0).max(16), z2.string()]).optional().describe("AE label colour, as an index 0-16 or a name (red, yellow, aqua, pink, lavender, peach, seafoam, blue, green, purple, orange, brown, fuchsia, cyan, sandstone, darkgreen).")
1514
+ label: z2.union([z2.number().int().min(0).max(16), z2.string()]).optional().describe("AE label colour, as an index 0-16 or a name (red, yellow, aqua, pink, lavender, peach, seafoam, blue, green, purple, orange, brown, fuchsia, cyan, sandstone, darkgreen)."),
1515
+ loop: z2.boolean().optional().describe("Loop the sound until `outPoint`, or until the end of the comp when no outPoint is given \u2014 for beds and ambiences. Enables time remapping with a wrap-around expression on Time Remap; the layer's in point still trims the front of the file the way it does on any layer. Default false: the file plays once."),
1516
+ fadeIn: z2.number().min(0).optional().describe("Seconds to fade in from `fadeFloorDb` up to `levelDb`, as keyframes on Audio Levels starting at the layer's in point. Default 0 (no fade). fadeIn + fadeOut must fit inside the cue's placed length or the cue is refused by index."),
1517
+ fadeOut: z2.number().min(0).optional().describe("Seconds to fade out from `levelDb` down to `fadeFloorDb`, ending at the layer's out point. Default 0 (no fade). fadeIn + fadeOut must fit inside the cue's placed length or the cue is refused by index."),
1518
+ stretch: z2.number().positive().optional().describe("Time stretch as a percentage: 100 is unchanged, 200 plays at half speed (lower pitch, twice as long), 50 at double speed. Must be greater than 0. The layer's start time and any trim are re-asserted after it, since After Effects moves them when stretch changes. Default 100.")
1072
1519
  }).strict();
1073
1520
  var PlaceAudioCues = z2.object({
1074
1521
  compId: z2.number(),
1075
1522
  cues: z2.array(AudioCue).min(1).max(200),
1076
1523
  namePrefix: z2.string().default("SFX_").optional().describe('Prefix for cues that do not name themselves. Pass "" for no prefix.'),
1077
- dryRun: z2.boolean().default(false).optional().describe("Resolve and check the whole list without importing, creating or changing anything \u2014 not even an undo step. Reports which paths do not exist and what would be placed.")
1524
+ fadeFloorDb: z2.number().optional().describe("The level in decibels that every fadeIn starts from and every fadeOut ends at. Default -48, the bottom of After Effects' own Audio Levels slider and 1/256 of the recorded amplitude \u2014 near-silence. Must be below each faded cue's levelDb. One floor for the whole list."),
1525
+ dryRun: z2.boolean().default(false).optional().describe("Resolve and check the whole list without importing, creating or changing anything \u2014 not even an undo step. Returns counts, the files it would import, the items it would reuse and the failing cues by index \u2014 never the whole resolved list.")
1078
1526
  }).strict();
1079
1527
  var ExportMogrt = z2.object({
1080
1528
  compId: z2.number(),
@@ -1093,12 +1541,26 @@ var SetHouseStyle = z2.object({
1093
1541
  }).strict();
1094
1542
  var CheckSetup = z2.object({}).strict();
1095
1543
  var SetupPanel = z2.object({
1096
- enableDebugMode: z2.boolean().default(true).optional().describe("Also enable Adobe's PlayerDebugMode preference, which AE requires to load this unsigned panel. Default true."),
1544
+ enableDebugMode: z2.boolean().default(true).optional().describe("Also set Adobe's PlayerDebugMode preference, which AE requires to load this unsigned panel, and CEP's LogLevel where it was never set, so CEP's own log records why a panel failed to load (issue #91). Default true."),
1097
1545
  force: z2.boolean().default(false).optional().describe("Replace an existing symlinked (development) install with a copy. Default false.")
1098
1546
  }).strict();
1099
- var GUIDE_TOPICS = ["ae-setup", "after-effects", "extendscript-gotchas", "style-guide", "whats-new"];
1547
+ var GUIDE_TOPICS = [
1548
+ "ae-setup",
1549
+ "after-effects",
1550
+ "animation",
1551
+ "assembly",
1552
+ "extendscript-gotchas",
1553
+ "issue-journal",
1554
+ "mogrt-and-footage",
1555
+ "shapes",
1556
+ "sound",
1557
+ "style-guide",
1558
+ "text",
1559
+ "whats-new"
1560
+ ];
1100
1561
  var AeGuide = z2.object({
1101
- topic: z2.enum(GUIDE_TOPICS).describe("after-effects: building, animating, easing, expressions, the traps \u2014 start here. extendscript-gotchas: read before writing raw ExtendScript for run_jsx. whats-new: what changed recently, when a call behaves differently from what you expected. style-guide: capturing the user's look. ae-setup: connecting to AE when a tool cannot reach it.")
1562
+ topic: z2.enum(GUIDE_TOPICS).describe("after-effects: the core \u2014 orienting, bounded reads, verifying a write, screenshots, the bridge failures \u2014 start here; it points at one topic per subject. animation: keyframes, easing, rigging, expressions. shapes: shape layers and their Contents. text: text layers. assembly: shots placed into a master comp, markers, retiming. extendscript-gotchas: read before writing raw ExtendScript for run_jsx. sound: sound effects and levels. mogrt-and-footage: exporting a .mogrt, importing footage. issue-journal: when a tool fought back. whats-new: what changed, one section per release, when a call behaves differently from what you expected or a project is being brought up to date after an upgrade. style-guide: capturing the user's look. ae-setup: connecting to AE when a tool cannot reach it."),
1563
+ since: z2.string().regex(/^v?\d+\.\d+\.\d+$/, "must be a version like 0.4.0").optional().describe("whats-new only: return just the releases newer than this version \u2014 the version named is the one already absorbed and is excluded \u2014 behind the guide's preamble, or one line saying nothing is newer. Use the version a project's docs record as last absorbed. The answer opens with the server's own version. Rejected on any other topic, since there is nothing there to filter.")
1102
1564
  }).strict();
1103
1565
  var InitProject = z2.object({
1104
1566
  dir: z2.string().optional().describe("Folder to create or fill, absolute or relative to the server's working directory. Ask the user if you do not know; do not invent one."),
@@ -1111,7 +1573,9 @@ var LogIssue = z2.object({
1111
1573
  symptom: z2.string().min(3).describe("What went wrong, including the exact error text and the call that produced it."),
1112
1574
  workaround: z2.string().min(3).describe("What actually worked \u2014 concrete enough for the next session to apply without rediscovering it."),
1113
1575
  cause: z2.string().optional().describe("Why it happens, if you worked it out."),
1114
- tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease']."),
1576
+ tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease']. With errorText, this is what a later failure is matched on."),
1577
+ errorText: z2.string().optional().describe("The exact error text the failing call returned, verbatim. The next time a call to one of `tools` fails with matching text, its error names this entry \u2014 and a later log_issue with the same tool and error text extends this entry instead of creating a second one under a different title."),
1578
+ kind: z2.enum(["tool-bug", "ae-quirk"]).default("tool-bug").optional().describe("'tool-bug' (default): something these tools get wrong, which a later release may fix \u2014 so the entry is archived once it was last seen on an older server than the one running. 'ae-quirk': After Effects itself behaving unlike its documentation; no release changes that, so it is never archived by version."),
1115
1579
  scope: z2.enum(["project", "user"]).default("project").optional().describe("'project' (default) for this project's footage, comps or files. 'user' for how these tools or After Effects behave \u2014 that journal travels with the person, so every future project starts knowing it. Reported back as 'home' when there is no project folder to write into.")
1116
1580
  }).strict();
1117
1581
  var ListKnownIssues = z2.object({
@@ -1119,14 +1583,19 @@ var ListKnownIssues = z2.object({
1119
1583
  tool: z2.string().optional().describe("Only entries about this tool, e.g. 'set_temporal_ease'. Omit for everything."),
1120
1584
  query: z2.string().optional().describe("Free-text filter: every whitespace-separated term must appear in an entry's title, symptom or tools."),
1121
1585
  id: z2.string().optional().describe("Read one entry in full \u2014 cause and workaround included \u2014 by the id from a previous listing. Ignores the filters. Ids are unique only within a journal, so prefix with the entry's scope ('user:my-entry') when the listing shows one in each."),
1122
- detail: z2.enum(["index", "full"]).default("index").optional().describe("'index' (default) is one line per entry: id, title, tools, counts and a one-line summary \u2014 read the one you need with `id`. 'full' returns every matching entry's whole body and costs thousands of tokens."),
1586
+ detail: z2.enum(["index", "full"]).default("index").optional().describe("'index' (default) is one line per entry: id, title, tools, kind, when and on which version it was last seen, counts \u2014 read the one you need with `id`. 'full' returns every matching entry's whole body and costs thousands of tokens."),
1123
1587
  scope: z2.enum(["all", "project", "user"]).default("all").optional().describe("Which journal to read. 'all' (default) merges this project's with the user's cross-project one and tags every entry with the scope it came from."),
1124
- limit: z2.number().int().positive().max(500).default(50).optional().describe("Most entries to return. Anything held back is counted in `omitted`.")
1588
+ limit: z2.number().int().positive().max(500).default(50).optional().describe("Most entries to return. Anything held back is counted in `omitted`."),
1589
+ includeArchived: z2.boolean().default(false).optional().describe("Also list archived entries \u2014 not seen for 30 days, last seen on an older server than this one, or retired with archive_issue \u2014 each flagged with its reason. Default false: they are hidden and only counted in `archivedCount`. An `id` read always returns the entry, archived or not.")
1125
1590
  }).strict();
1126
1591
  var MarkIssueReported = z2.object({
1127
1592
  id: z2.string().describe("The entry id returned by log_issue or list_known_issues. Prefix with its scope ('user:my-entry') when the same id exists in both journals."),
1128
1593
  url: z2.string().optional().describe("Link to the issue that was opened.")
1129
1594
  }).strict();
1595
+ var ArchiveIssue = z2.object({
1596
+ id: z2.string().describe("The entry id from log_issue or list_known_issues. Prefix with its scope ('user:my-entry') when the same id exists in both journals \u2014 only the one named moves."),
1597
+ reason: z2.string().min(3).describe("Why it is being retired, one line \u2014 the URL of the report that already covers it, the release that fixed it, or where the lesson was moved to.")
1598
+ }).strict();
1130
1599
  var AwaitJob = z2.object({ jobId: z2.string(), timeoutMs: z2.number().int().positive().default(6e5).optional() });
1131
1600
  var GetJob = z2.object({ jobId: z2.string() });
1132
1601
  var CancelJob = z2.object({ jobId: z2.string() });
@@ -1204,6 +1673,7 @@ var OpSchemas = {
1204
1673
  // footage
1205
1674
  import_footage: ImportFootage,
1206
1675
  create_footage_layer: CreateFootageLayer,
1676
+ purge_unused_footage: PurgeUnusedFootage,
1207
1677
  // audio
1208
1678
  place_audio_cues: PlaceAudioCues,
1209
1679
  // motion graphics templates
@@ -1226,7 +1696,8 @@ var OpSchemas = {
1226
1696
  // issue journal
1227
1697
  log_issue: LogIssue,
1228
1698
  list_known_issues: ListKnownIssues,
1229
- mark_issue_reported: MarkIssueReported
1699
+ mark_issue_reported: MarkIssueReported,
1700
+ archive_issue: ArchiveIssue
1230
1701
  };
1231
1702
  var OpMutation = {
1232
1703
  // comps
@@ -1273,262 +1744,69 @@ var OpMutation = {
1273
1744
  set_expression: "write",
1274
1745
  toggle_expression: "write",
1275
1746
  clear_expression: "write",
1276
- // effects
1277
- list_effects: "read",
1278
- add_effect: "write",
1279
- remove_effect: "write",
1280
- set_effect_param: "write",
1281
- set_effect_enabled: "write",
1282
- list_available_effects: "read",
1283
- // text
1284
- set_text: "write",
1285
- add_text_animator: "write",
1286
- // shapes
1287
- set_shape_path: "write",
1288
- add_shape_content: "write",
1289
- set_shape_property: "write",
1290
- // masks
1291
- add_mask: "write",
1292
- set_mask: "write",
1293
- remove_mask: "write",
1294
- // markers
1295
- add_marker: "write",
1296
- remove_marker: "write",
1297
- // vision — read-only despite being the slowest thing here. `screenshot_*`
1298
- // borrows the comp's resolutionFactor and restores it in a `finally`;
1299
- // nothing in the project changes.
1300
- screenshot_frame: "read",
1301
- screenshot_layer: "read",
1302
- // batch
1303
- run_batch: "write",
1304
- // explore
1305
- get_project_summary: "read",
1306
- find_layers: "read",
1307
- // footage
1308
- import_footage: "write",
1309
- create_footage_layer: "write",
1310
- // audio cues — imports footage and adds layers.
1311
- place_audio_cues: "write",
1312
- // motion graphics templates — saves the project before exporting.
1313
- export_mogrt: "write",
1314
- // raw — the script is the caller's and may do anything. Assume the worst.
1315
- run_jsx: "write",
1316
- // house style — written over the bridge into a file beside the .aep.
1317
- get_house_style: "read",
1318
- set_house_style: "write",
1319
- // jobs
1320
- await_job: "server",
1321
- get_job: "server",
1322
- cancel_job: "server",
1323
- // setup
1324
- check_setup: "server",
1325
- setup_panel: "server",
1326
- init_project: "server",
1327
- // guidance
1328
- ae_guide: "server",
1329
- // issue journal
1330
- log_issue: "server",
1331
- list_known_issues: "server",
1332
- mark_issue_reported: "server"
1333
- };
1334
-
1335
- // src/util/errors.ts
1336
- var BridgeUnreachableError = class _BridgeUnreachableError extends Error {
1337
- constructor(port, cause) {
1338
- super(_BridgeUnreachableError.message(port, cause));
1339
- this.port = port;
1340
- this.name = "BridgeUnreachableError";
1341
- }
1342
- port;
1343
- static message(port, cause) {
1344
- return [
1345
- `Cannot reach the After Effects panel at http://127.0.0.1:${port}.`,
1346
- cause ? `Underlying error: ${cause.message}` : "",
1347
- "",
1348
- "Call the check_setup tool to find out why, then relay its nextSteps to the user.",
1349
- "In most cases the fix is either running setup_panel (installs the panel) or",
1350
- "simply opening After Effects."
1351
- ].filter(Boolean).join("\n");
1352
- }
1353
- };
1354
- var BridgeTimeoutError = class _BridgeTimeoutError extends Error {
1355
- constructor(port, timeoutMs, opts = {}) {
1356
- super(_BridgeTimeoutError.message(port, timeoutMs, opts));
1357
- this.port = port;
1358
- this.timeoutMs = timeoutMs;
1359
- this.name = "BridgeTimeoutError";
1360
- }
1361
- port;
1362
- timeoutMs;
1363
- static message(port, timeoutMs, opts = {}) {
1364
- const secs = Math.round(timeoutMs / 1e3);
1365
- const what = opts.op ? `\`${opts.op}\`` : "the call";
1366
- return [
1367
- `The After Effects panel at http://127.0.0.1:${port} did not answer within ${secs}s for ${what}.`,
1368
- "",
1369
- "This is a timeout, not a lost connection \u2014 the panel is very probably still",
1370
- "there. ExtendScript is single-threaded, so while After Effects is busy running a",
1371
- "script it cannot answer the bridge at all, and a busy AE looks exactly like a",
1372
- "dead one from here. It usually recovers on its own.",
1373
- "",
1374
- "What to do, in order:",
1375
- "1. Do not restart After Effects, and do not run setup_panel. Do not re-send the",
1376
- " call either \u2014 that would queue a second copy of the same work.",
1377
- "2. Call check_setup, and keep polling it for about a minute. The bridge normally",
1378
- " comes back by itself once the script finishes.",
1379
- "3. Ask the user to look at After Effects. A modal dialog \u2014 an unsaved-project",
1380
- " prompt, a missing-font warning \u2014 blocks it in the same way and may be hidden",
1381
- " behind another window. Only they can click it.",
1382
- "4. On macOS, if they have switched to another desktop (Space), ask them to switch",
1383
- " back to the one After Effects is on. Calls have been reported to stall until",
1384
- " they return, then complete normally.",
1385
- "5. Only if check_setup still reports the bridge down after a minute should you",
1386
- " treat this as a real disconnection and follow its nextSteps.",
1387
- opts.adjustable === false ? "" : `
1388
- If this work legitimately takes longer than ${secs}s, the limit is settable: start the
1389
- server with AE_MCP_OP_TIMEOUT_MS set to a larger number of milliseconds.`
1390
- ].filter(Boolean).join("\n");
1391
- }
1392
- };
1393
- function isTimeoutError(e, depth = 0) {
1394
- if (!e || typeof e !== "object" || depth > 4) return false;
1395
- const name = e.name;
1396
- if (name === "TimeoutError" || name === "AbortError") return true;
1397
- const cause = e.cause;
1398
- return cause === e ? false : isTimeoutError(cause, depth + 1);
1399
- }
1400
- var AeError = class extends Error {
1401
- /**
1402
- * `code` is set only when the panel diagnosed the failure itself rather than
1403
- * relaying one from ExtendScript — `STALE_FRAME` is the first. Those messages
1404
- * already read as complete instructions, so the caller uses this to decide
1405
- * whether an `AE:` prefix would help or just obscure them.
1406
- */
1407
- constructor(message, stack_, line, code, source) {
1408
- super(message);
1409
- this.stack_ = stack_;
1410
- this.line = line;
1411
- this.code = code;
1412
- this.source = source;
1413
- this.name = "AeError";
1414
- }
1415
- stack_;
1416
- line;
1417
- code;
1418
- source;
1419
- };
1420
- function aeErrorText(e) {
1421
- const head = `AE: ${e.message}`;
1422
- const s = e.source;
1423
- if (!s) return e.line ? `${head} (line ${e.line})` : head;
1424
- const where = s.sourceName ?? "the script you submitted";
1425
- const total = s.lineCount ? `, ${s.lineCount} lines` : "";
1426
- const lines = [head];
1427
- if (s.sourceLine) {
1428
- lines.push(` at line ${s.sourceLine} of ${where}${total}:`);
1429
- if (s.sourceText) lines.push(` ${s.sourceText}`);
1430
- if (s.rawLine != null && s.rawLine !== s.sourceLine) {
1431
- lines.push(` (After Effects reported line ${s.rawLine}.)`);
1432
- }
1433
- } else if (s.rawLine != null) {
1434
- lines.push(
1435
- ` After Effects reported line ${s.rawLine}, which does not fall inside ${where}${total} \u2014 trust the message, not the number.`
1436
- );
1437
- }
1438
- lines.push(
1439
- " Everything before the failure already ran and nothing rolls back: read the state back rather than re-running the script."
1440
- );
1441
- return lines.join("\n");
1442
- }
1443
- var WriteQueueWaitError = class _WriteQueueWaitError extends Error {
1444
- constructor(op, waitedMs, behind) {
1445
- super(_WriteQueueWaitError.message(op, waitedMs, behind));
1446
- this.op = op;
1447
- this.waitedMs = waitedMs;
1448
- this.behind = behind;
1449
- this.name = "WriteQueueWaitError";
1450
- }
1451
- op;
1452
- waitedMs;
1453
- behind;
1454
- static message(op, waitedMs, behind) {
1455
- const secs = Math.round(waitedMs / 1e3);
1456
- return [
1457
- `\`${op}\` waited ${secs}s behind \`${behind}\` for the After Effects write queue and was dropped without running.`,
1458
- "",
1459
- "This is neither a lost connection nor a busy bridge. The panel is fine and this",
1460
- "call never reached After Effects, so nothing in the project was changed.",
1461
- "",
1462
- "Writes are serialized because After Effects runs one script at a time and applies",
1463
- "changes in the order it receives them; two in flight block each other and land in",
1464
- "an order nobody chose. Something in front of this call is taking a very long time",
1465
- "\u2014 most often a long run_batch, occasionally a modal dialog in After Effects",
1466
- "blocking the script in front.",
1467
- "",
1468
- "What to do, in order:",
1469
- "1. Nothing was written, so re-sending this call is safe \u2014 unlike a bridge timeout.",
1470
- " Wait for the work in front to finish first, or it will just queue again.",
1471
- "2. Find out what is in front: get_job or await_job for a batch. Reads are not",
1472
- " queued, so list_/get_ calls still work and will tell you the current state.",
1473
- "3. Ask the user to look at After Effects for a dialog nobody has clicked.",
1474
- `4. If the work in front is legitimately this long, raise the limit: start the`,
1475
- " server with AE_MCP_WRITE_QUEUE_WAIT_MS set to a larger number of milliseconds."
1476
- ].join("\n");
1477
- }
1478
- };
1479
- var WriteQueueFullError = class _WriteQueueFullError extends Error {
1480
- constructor(op, maxDepth, behind) {
1481
- super(_WriteQueueFullError.message(op, maxDepth, behind));
1482
- this.op = op;
1483
- this.maxDepth = maxDepth;
1484
- this.behind = behind;
1485
- this.name = "WriteQueueFullError";
1486
- }
1487
- op;
1488
- maxDepth;
1489
- behind;
1490
- static message(op, maxDepth, behind) {
1491
- return [
1492
- `The After Effects write queue is full \u2014 ${maxDepth} calls are already waiting, so \`${op}\` was refused.`,
1493
- "",
1494
- `Nothing was written. Writes are serialized so they land in the order you sent them,`,
1495
- `and \`${behind}\` is holding the queue up.`,
1496
- "",
1497
- "Stop issuing writes and let it drain \u2014 reads are not queued, so list_/get_ calls",
1498
- "still work. If you have this much independent work to do, send it as one",
1499
- "run_batch instead of as many calls: that is one ExtendScript pass and one place in",
1500
- "the queue, and its result reports how many undo steps it actually made.",
1501
- "AE_MCP_WRITE_QUEUE_DEPTH raises the limit if you really need it raised."
1502
- ].join("\n");
1503
- }
1504
- };
1505
- var WriteQueueCancelledError = class extends Error {
1506
- constructor(op) {
1507
- super(`\`${op}\` was cancelled while waiting for the After Effects write queue. It never ran, and nothing was changed.`);
1508
- this.op = op;
1509
- this.name = "WriteQueueCancelledError";
1510
- }
1511
- op;
1747
+ // effects
1748
+ list_effects: "read",
1749
+ add_effect: "write",
1750
+ remove_effect: "write",
1751
+ set_effect_param: "write",
1752
+ set_effect_enabled: "write",
1753
+ list_available_effects: "read",
1754
+ // text
1755
+ set_text: "write",
1756
+ add_text_animator: "write",
1757
+ // shapes
1758
+ set_shape_path: "write",
1759
+ add_shape_content: "write",
1760
+ set_shape_property: "write",
1761
+ // masks
1762
+ add_mask: "write",
1763
+ set_mask: "write",
1764
+ remove_mask: "write",
1765
+ // markers
1766
+ add_marker: "write",
1767
+ remove_marker: "write",
1768
+ // vision — read-only despite being the slowest thing here. `screenshot_*`
1769
+ // borrows the comp's resolutionFactor and restores it in a `finally`;
1770
+ // nothing in the project changes.
1771
+ screenshot_frame: "read",
1772
+ screenshot_layer: "read",
1773
+ // batch
1774
+ run_batch: "write",
1775
+ // explore
1776
+ get_project_summary: "read",
1777
+ find_layers: "read",
1778
+ // footage
1779
+ import_footage: "write",
1780
+ create_footage_layer: "write",
1781
+ // Removes project items. A dryRun changes nothing, but the table is per op,
1782
+ // not per call — a dry run waiting behind a batch costs a little time and a
1783
+ // real one interleaving with it costs the batch's undo step.
1784
+ purge_unused_footage: "write",
1785
+ // audio cues — imports footage and adds layers.
1786
+ place_audio_cues: "write",
1787
+ // motion graphics templates — saves the project before exporting.
1788
+ export_mogrt: "write",
1789
+ // raw — the script is the caller's and may do anything. Assume the worst.
1790
+ run_jsx: "write",
1791
+ // house style — written over the bridge into a file beside the .aep.
1792
+ get_house_style: "read",
1793
+ set_house_style: "write",
1794
+ // jobs
1795
+ await_job: "server",
1796
+ get_job: "server",
1797
+ cancel_job: "server",
1798
+ // setup
1799
+ check_setup: "server",
1800
+ setup_panel: "server",
1801
+ init_project: "server",
1802
+ // guidance
1803
+ ae_guide: "server",
1804
+ // issue journal
1805
+ log_issue: "server",
1806
+ list_known_issues: "server",
1807
+ mark_issue_reported: "server",
1808
+ archive_issue: "server"
1512
1809
  };
1513
- function invalidArgsText(op, e) {
1514
- const issues = e?.issues;
1515
- if (!Array.isArray(issues) || issues.length === 0) {
1516
- return `Invalid arguments for ${op}: ${e.message}`;
1517
- }
1518
- const lines = issues.map((raw) => {
1519
- const issue = raw;
1520
- const where = Array.isArray(issue.path) && issue.path.length ? issue.path.join(".") : null;
1521
- let text = issue.message ?? "invalid";
1522
- if (issue.code === "invalid_type" && issue.received === "undefined") {
1523
- text = "required, and was not passed";
1524
- } else if (issue.code === "invalid_type" && issue.expected) {
1525
- text = `expected ${issue.expected}, got ${issue.received ?? "something else"}`;
1526
- }
1527
- return where ? ` - ${where}: ${text}` : ` - ${text}`;
1528
- });
1529
- const head = issues.length === 1 ? `Invalid arguments for ${op}:` : `Invalid arguments for ${op} (${issues.length} problems):`;
1530
- return [head, ...lines].join("\n");
1531
- }
1532
1810
 
1533
1811
  // src/util/logger.ts
1534
1812
  var logger = {
@@ -1540,32 +1818,18 @@ var logger = {
1540
1818
  }
1541
1819
  };
1542
1820
 
1543
- // src/bridge/discovery.ts
1544
- import fs2 from "node:fs";
1545
- import os2 from "node:os";
1546
- import path3 from "node:path";
1547
- var DEFAULT_PORT = 7777;
1548
- function discoverPort() {
1549
- const envPort = process.env.AE_MCP_PORT;
1550
- if (envPort) {
1551
- const n = parseInt(envPort, 10);
1552
- if (Number.isFinite(n)) return n;
1553
- }
1554
- try {
1555
- const f = path3.join(os2.homedir(), ".engineroom-ae-mcp", "port");
1556
- if (fs2.existsSync(f)) {
1557
- const txt = fs2.readFileSync(f, "utf8").trim();
1558
- const n = parseInt(txt, 10);
1559
- if (Number.isFinite(n)) return n;
1560
- }
1561
- } catch {
1562
- }
1563
- return DEFAULT_PORT;
1564
- }
1565
-
1566
1821
  // src/bridge/httpClient.ts
1567
1822
  var DEFAULT_OP_TIMEOUT_MS = 12e4;
1568
- var SLOW_OPS = /* @__PURE__ */ new Set(["run_batch", "run_jsx", "screenshot_frame", "screenshot_layer", "export_mogrt", "import_footage", "place_audio_cues"]);
1823
+ var SLOW_OPS = /* @__PURE__ */ new Set([
1824
+ "run_batch",
1825
+ "run_jsx",
1826
+ "screenshot_frame",
1827
+ "screenshot_layer",
1828
+ "export_mogrt",
1829
+ "import_footage",
1830
+ "place_audio_cues",
1831
+ "purge_unused_footage"
1832
+ ]);
1569
1833
  var SLOW_OP_TIMEOUT_MS = 3e5;
1570
1834
  function opTimeoutMs(op) {
1571
1835
  const raw = process.env.AE_MCP_OP_TIMEOUT_MS?.trim();
@@ -1577,12 +1841,61 @@ function opTimeoutMs(op) {
1577
1841
  return op && SLOW_OPS.has(op) ? SLOW_OP_TIMEOUT_MS : DEFAULT_OP_TIMEOUT_MS;
1578
1842
  }
1579
1843
  var HEALTH_TIMEOUT_MS = 2e3;
1844
+ var LISTENER_SETTLE_MS = 750;
1845
+ function probeSummary(located) {
1846
+ if (located.probed.length === 0) return "";
1847
+ const busy = located.probed.find((p) => p.status === "busy");
1848
+ const parts = located.probed.map((p) => p.detail);
1849
+ const head = `Also looked for the panel on port${located.probed.length > 1 ? "s" : ""} ${located.probed.map((p) => p.port).join(", ")}: ${parts.join("; ")}.`;
1850
+ const pinned = pinnedPort();
1851
+ const pin = pinned !== null ? ` AE_MCP_PORT pins this server to port ${pinned}, so no other port was tried; unset it (or point it at the port check_setup reports) to let the server look at ${DEFAULT_PORT} and the port file.` : "";
1852
+ return busy ? `${head} A listener on port ${busy.port} may be the panel with After Effects busy; the next call looks again.${pin}` : `${head}${pin}`;
1853
+ }
1580
1854
  var HttpClient = class {
1581
1855
  port;
1582
1856
  base;
1583
- constructor(port) {
1857
+ /** The port this client started on — what a later drift is measured against. */
1858
+ initialPort;
1859
+ listeners = [];
1860
+ candidatesFn;
1861
+ constructor(port, opts = {}) {
1584
1862
  this.port = port ?? discoverPort();
1863
+ this.initialPort = this.port;
1585
1864
  this.base = `http://127.0.0.1:${this.port}`;
1865
+ this.candidatesFn = opts.candidates ?? portCandidates;
1866
+ }
1867
+ /** The ports a rediscovery will ask, in order. Exposed so `check_setup` asks the same ones. */
1868
+ candidates() {
1869
+ return this.candidatesFn();
1870
+ }
1871
+ onPortChange(listener) {
1872
+ this.listeners.push(listener);
1873
+ }
1874
+ /**
1875
+ * Move to another port and let the dependants follow before anything else
1876
+ * is sent there. The wait is bounded: a listener that never settles must not
1877
+ * turn a port switch into a hang.
1878
+ */
1879
+ async switchPort(next) {
1880
+ const previous = this.port;
1881
+ if (next === previous) return;
1882
+ this.port = next;
1883
+ this.base = `http://127.0.0.1:${next}`;
1884
+ logger.warn(`Bridge port changed: ${previous} -> ${next}`);
1885
+ await Promise.race([
1886
+ Promise.all(this.listeners.map((l) => Promise.resolve().then(() => l(next, previous)).catch((e) => {
1887
+ logger.warn(`port-change listener failed: ${e.message}`);
1888
+ }))),
1889
+ new Promise((r) => setTimeout(r, LISTENER_SETTLE_MS).unref?.())
1890
+ ]);
1891
+ }
1892
+ /**
1893
+ * Ask the candidate ports where the panel is now. Returns the probe that
1894
+ * answered as the panel, or null — and the whole list of what was asked, so
1895
+ * a failure can say what it looked at.
1896
+ */
1897
+ async rediscover() {
1898
+ return locatePanel(this.candidates());
1586
1899
  }
1587
1900
  // `bundleHash` is absent on panels installed before it was added; callers must
1588
1901
  // treat undefined as "too old to say" rather than as a mismatch.
@@ -1598,7 +1911,12 @@ var HttpClient = class {
1598
1911
  throw new BridgeUnreachableError(this.port, e);
1599
1912
  }
1600
1913
  }
1601
- async runOp(op, args, progressToken) {
1914
+ /**
1915
+ * One POST to /op on the current port. Its own `AbortSignal.timeout`, created
1916
+ * here, so a retry after a port switch gets the full budget rather than the
1917
+ * remainder of a clock that started before the refusal.
1918
+ */
1919
+ async postOp(op, args, progressToken) {
1602
1920
  let resp;
1603
1921
  const timeoutMs = opTimeoutMs(op);
1604
1922
  try {
@@ -1623,6 +1941,43 @@ var HttpClient = class {
1623
1941
  }
1624
1942
  return data.result;
1625
1943
  }
1944
+ /**
1945
+ * Forward an op, following the panel if it has moved.
1946
+ *
1947
+ * The port is discovered once, at construction, and every op posts there —
1948
+ * which is how `check_setup` could report the panel healthy on 7777 while
1949
+ * every op failed on 7778 for a week (issue #92): the panel had moved after
1950
+ * this server read the port file. So a *refused* connection is taken as a
1951
+ * cue to look again. Only a refusal, and this is the whole safety argument:
1952
+ * refused means the request never reached a panel, so sending it again
1953
+ * cannot run anything twice. A timeout is the opposite case — the call did
1954
+ * reach After Effects and may still be running — and must never be retried
1955
+ * (issue #43 is what a duplicated `run_jsx` costs).
1956
+ *
1957
+ * The retry happens only when the search turns up a *different* port that
1958
+ * answers as the panel. The same port answering again means the refusal was
1959
+ * something else, and that is the original error's story to tell.
1960
+ */
1961
+ async runOp(op, args, progressToken) {
1962
+ try {
1963
+ return await this.postOp(op, args, progressToken);
1964
+ } catch (e) {
1965
+ if (!(e instanceof BridgeUnreachableError)) throw e;
1966
+ const refusedPort = this.port;
1967
+ const located = await this.rediscover();
1968
+ if (located.found && located.found.port !== refusedPort) {
1969
+ logger.warn(
1970
+ `\`${op}\` was refused on port ${refusedPort}; the panel is answering on port ${located.found.port}. Switching and re-sending once.`
1971
+ );
1972
+ await this.switchPort(located.found.port);
1973
+ return await this.postOp(op, args, progressToken);
1974
+ }
1975
+ const summary = probeSummary(located);
1976
+ if (summary) e.message += `
1977
+ ${summary}`;
1978
+ throw e;
1979
+ }
1980
+ }
1626
1981
  async cancel(jobId) {
1627
1982
  try {
1628
1983
  await fetch(`${this.base}/cancel`, {
@@ -1639,11 +1994,12 @@ var HttpClient = class {
1639
1994
  // src/bridge/wsClient.ts
1640
1995
  import WebSocket from "ws";
1641
1996
  var WsClient = class {
1642
- constructor(port, jobs) {
1643
- this.port = port;
1997
+ constructor(bridge, jobs) {
1998
+ this.bridge = bridge;
1644
1999
  this.jobs = jobs;
2000
+ bridge.onPortChange(() => this.reconnect());
1645
2001
  }
1646
- port;
2002
+ bridge;
1647
2003
  jobs;
1648
2004
  ws;
1649
2005
  reconnectTimer;
@@ -1659,26 +2015,46 @@ var WsClient = class {
1659
2015
  } catch {
1660
2016
  }
1661
2017
  }
2018
+ /** Drop the current socket and connect to wherever the bridge is now. Resolves on open, or on failure. */
2019
+ reconnect() {
2020
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2021
+ const old = this.ws;
2022
+ this.ws = void 0;
2023
+ try {
2024
+ old?.removeAllListeners();
2025
+ old?.on("error", () => {
2026
+ });
2027
+ old?.close();
2028
+ } catch {
2029
+ }
2030
+ return this.connect();
2031
+ }
1662
2032
  connect() {
1663
- if (this.stopped) return;
1664
- const url = `ws://127.0.0.1:${this.port}/events`;
2033
+ if (this.stopped) return Promise.resolve();
2034
+ const url = `ws://127.0.0.1:${this.bridge.port}/events`;
1665
2035
  const ws = new WebSocket(url);
1666
2036
  this.ws = ws;
1667
- ws.on("open", () => logger.debug("WS connected", url));
1668
- ws.on("message", (data) => {
1669
- let evt;
1670
- try {
1671
- evt = JSON.parse(data.toString());
1672
- } catch {
1673
- return;
1674
- }
1675
- this.handleEvent(evt);
1676
- });
1677
- ws.on("close", () => {
1678
- if (this.stopped) return;
1679
- this.reconnectTimer = setTimeout(() => this.connect(), 1500);
1680
- });
1681
- ws.on("error", () => {
2037
+ return new Promise((resolve) => {
2038
+ ws.on("open", () => {
2039
+ logger.debug("WS connected", url);
2040
+ resolve();
2041
+ });
2042
+ ws.on("message", (data) => {
2043
+ let evt;
2044
+ try {
2045
+ evt = JSON.parse(data.toString());
2046
+ } catch {
2047
+ return;
2048
+ }
2049
+ this.handleEvent(evt);
2050
+ });
2051
+ ws.on("close", () => {
2052
+ resolve();
2053
+ if (this.stopped || this.ws !== ws) return;
2054
+ this.reconnectTimer = setTimeout(() => this.connect(), 1500);
2055
+ });
2056
+ ws.on("error", () => {
2057
+ });
1682
2058
  });
1683
2059
  }
1684
2060
  handleEvent(evt) {
@@ -1877,10 +2253,34 @@ var JobManager = class {
1877
2253
  this.jobs.set(jobId, s);
1878
2254
  return s;
1879
2255
  }
2256
+ /**
2257
+ * Forward this job's progress to `fn` until the job settles or the returned
2258
+ * function is called.
2259
+ *
2260
+ * The caller owns the lifetime, and the returned unbind is not optional: an
2261
+ * emitter is bound for the span of one MCP request — `await_job` — and one
2262
+ * left behind would keep sending `notifications/progress` on that request's
2263
+ * token after its response, which is exactly what a spec-compliant client
2264
+ * stops listening for the moment the response arrives (issue #82). `flush`
2265
+ * drops every emitter when the job finishes, so unbinding after that is a
2266
+ * no-op rather than an error.
2267
+ */
1880
2268
  bindProgressEmitter(jobId, fn) {
1881
2269
  const arr = this.progressEmitters.get(jobId) ?? [];
1882
2270
  arr.push(fn);
1883
2271
  this.progressEmitters.set(jobId, arr);
2272
+ return () => this.unbindProgressEmitter(jobId, fn);
2273
+ }
2274
+ unbindProgressEmitter(jobId, fn) {
2275
+ const arr = this.progressEmitters.get(jobId);
2276
+ if (!arr) return;
2277
+ const i = arr.indexOf(fn);
2278
+ if (i >= 0) arr.splice(i, 1);
2279
+ if (arr.length === 0) this.progressEmitters.delete(jobId);
2280
+ }
2281
+ /** How many emitters a job currently has. Exists for the tests. */
2282
+ progressEmitterCount(jobId) {
2283
+ return this.progressEmitters.get(jobId)?.length ?? 0;
1884
2284
  }
1885
2285
  reportProgress(jobId, progress, total, message) {
1886
2286
  const s = this.jobs.get(jobId);
@@ -2016,7 +2416,7 @@ var descriptions = {
2016
2416
  get_comp_tree: "Comp + nested layer tree, recursing pre-comps to `depth`.",
2017
2417
  create_comp: "Create a new comp. Returns id.",
2018
2418
  set_comp: "Modify a comp (name, dims, fps, duration, work area, bg). Undefined fields unchanged.",
2019
- delete_comp: "Delete a comp. Reversible only via AE's Undo.",
2419
+ delete_comp: "Delete a comp. Reversible only via AE's Undo. The solids its layers used are project items, not part of the comp, and After Effects leaves them behind in the Solids folder \u2014 the result counts them in `unusedSolidsLeft`. Pass `purgeUnusedSolids:true` to remove, in the same undo step, the ones nothing else uses; a solid another comp still uses is kept and reported in `keptSolids` with that comp's id. Only this comp's own solids are ever considered \u2014 for the whole project's orphans use purge_unused_footage.",
2020
2420
  set_active_comp: "Focus a comp in the viewer/timeline.",
2021
2421
  duplicate_comp: "Copy a comp. Returns the new comp id, so you never have to find it by name. Use this instead of run_jsx + CompItem.duplicate(). By default the copy is SHALLOW, exactly like AE's own Duplicate: its precomp layers point at the same nested comps as the original, so editing one of those edits both. `deep:true` duplicates the nested comps too and re-points the copy at them \u2014 that is what 'a variant of this rig' means. A nested comp used by several layers is duplicated once and reused. `folderId` files the copy in a project folder; `nameSuffix` names the nested copies '<original><suffix>'.",
2022
2422
  snapshot_comp: "Take a cheap structural fingerprint of a comp and keep it in this server's memory. Returns a snapshotId and nothing else worth tokens \u2014 call it BEFORE a write, then diff_comp afterwards to learn what the write actually did, instead of reading the comp back and comparing by eye. It records per layer: id, name, index, type, in/out/start, parent, enabled, keyframe counts, expression count, effect count; per comp: size, duration, frame rate, work area, markers. It does NOT record property values, expression text, effect parameters, masks or shape contents. Nothing is written into the AE project, and snapshots are gone when the session ends.",
@@ -2024,7 +2424,7 @@ var descriptions = {
2024
2424
  // ---------- layers ----------
2025
2425
  list_layers: "Layers in a comp, one-line each. Use get_layer_full for details. Pass `include` to trim it \u2014 `include: []` returns just id/index/name/type, the cheapest way to learn what is in a comp.",
2026
2426
  get_layer_full: "Full state of one layer: transform + keyframes + expressions, effects, masks, markers, parenting, text/shape/footage extras, and sourceRect (visible bounds). Always prefer over multiple smaller queries. Bound the answer on a heavy layer: `include` picks the sections you need, `maxKeyframes` caps the keyframes per property, `shapeDepth` limits the Contents walk. Anything dropped is named and counted in the response, so a bounded read is never mistaken for a complete one. On a shape layer, `shapeDetail: 'compact'` returns one indented line per group \u2014 `name matchName prop=value prop=value`, with `[3 keys]`/`[expr]` marking animated properties and `(at defaults)` a group Transform nobody has touched \u2014 which is a fraction of the size and still names every node the write tools address. Material Options (48 3D-extrusion properties per group, inert on a 2D shape layer) is left out of both forms and counted in `materialsOmitted`; pass `shapeMaterials:true` for the extruded-3D case.",
2027
- create_text_layer: "Text layer with optional font/size/color/position/tracking. anchorAlign (default 'left') aligns the text by setting paragraph justification and leaving the anchor at [0,0], so position means the start of the baseline AND stays right when the text is changed later. Tracking is set to 0 unless you pass one, because AE otherwise inherits the user's Character panel. anchorAlign 'none' keeps AE's raw defaults.",
2427
+ create_text_layer: "Text layer with optional font/size/color/position/tracking. anchorAlign (default 'left') aligns the text by setting paragraph justification and leaving the anchor at [0,0], so position means the start of the baseline AND stays right when the text is changed later. Tracking is set to 0 unless you pass one, because AE otherwise inherits the user's Character panel. anchorAlign 'none' keeps AE's raw defaults. The justification is read back after the write (AE 26.3 has been seen to store a different alignment from the one set \u2014 issues #93/#94): the result carries `justification` as read and `justificationReasserted` when a second write was needed, and if it still disagrees the layer is removed and the call throws, so a success result is a layer aligned the way it says.",
2028
2428
  create_shape_layer: "Empty shape layer; fill via add_shape_content. Position defaults to [0,0] with Anchor Point [0,0], so the layer's coordinate space IS the comp's and every vertex, rect/ellipse position and path you add afterwards is in comp pixels. (After Effects' own default is the comp centre, which silently offsets a drawing authored in comp coordinates by half a frame \u2014 pass position:'center' if you want that, or any [x,y] to place the origin yourself.) The result echoes the position and anchor point it ended up with.",
2029
2429
  create_solid_layer: "Solid-color layer. color is RGB 0..1.",
2030
2430
  create_null_layer: "Null parent layer.",
@@ -2047,9 +2447,9 @@ var descriptions = {
2047
2447
  set_temporal_ease: "Set influence+speed for the in/out ease of one keyframe. Pass at least one of `easeIn`/`easeOut` \u2014 neither is refused, since it would change nothing. Pass ONE {influence, speed} pair per side and it is applied to every dimension of the property \u2014 you never size the ease array yourself. That matters because AE's own setTemporalEaseAtKey wants a per-property number of entries that is not derivable from the value (2D Scale takes 2, a shape Ellipse Size takes 3, Opacity and sliders take 1, and spatial Position takes 1 whether the layer is 2D or 3D), and the wrong count throws a bare 'parameter 2'. This derives the count, retries the alternatives, and reports what worked as `easeDimensions` \u2014 worth reading if you are also easing that property from run_jsx.",
2048
2448
  set_spatial_tangents: "Set in/out spatial tangents for a position-style keyframe.",
2049
2449
  // ---------- expressions ----------
2050
- get_expression: "Expression text + enabled state on a property.",
2051
- set_expression: "Set + enable an expression on a property.",
2052
- toggle_expression: "Enable/disable an expression without clearing.",
2450
+ get_expression: "Expression text, enabled state and `expressionError` on a property. `expressionError` is After Effects' own message when the expression cannot be compiled or evaluated \u2014 the text of the warning banner in the AE UI \u2014 or an empty string when it runs clean. A non-empty one means the property is not being driven by that expression, whatever `enabled` says.",
2451
+ set_expression: "Set + enable an expression on a property, and verify it: after the write the property is evaluated and, if After Effects reports an error on it, the call THROWS with that message and the property path. So a success result means the expression compiles and runs; a failure means the text was written but AE cannot evaluate it \u2014 fix it and call set_expression again, or clear_expression to remove it. A broken expression never comes back as ok.",
2452
+ toggle_expression: "Enable/disable an expression without clearing. Enabling verifies the same way set_expression does: if AE reports an error on the expression once it is enabled, the call throws with the message rather than returning ok.",
2053
2453
  clear_expression: "Remove an expression.",
2054
2454
  // ---------- effects ----------
2055
2455
  list_effects: "All effects on a layer with current param values + keyframes/expressions.",
@@ -2059,7 +2459,7 @@ var descriptions = {
2059
2459
  set_effect_enabled: "Toggle an effect on/off without removing.",
2060
2460
  list_available_effects: "Effects installed in this AE: displayName, matchName, category. **Always pass `filter`** to substring-search: the full list is 250-450+ entries and the cost is in returning them, not in reading them, so an unfiltered call takes seconds every time while a filtered one takes a fraction of one (measured on AE 26.3: 446 entries in 3.9s, 22 filtered in 0.17s). `refresh:true` re-reads after installing a plugin; the enumeration is cached per AE session, which is why refresh exists. Never loop over `app.effects` in run_jsx: it is slow enough to block the bridge past its timeout and looks like a crash.",
2061
2461
  // ---------- text ----------
2062
- set_text: "Set text content + styling (font, size, fill/stroke, tracking, leading, justification). Undefined fields unchanged.",
2462
+ set_text: "Set text content + styling (font, size, fill/stroke, tracking, leading, justification). Undefined fields unchanged \u2014 and for justification that is verified, not assumed: on AE 26.3 the Source Text round trip has been seen to reset a left-aligned layer to centre when only `text` was changed (issue #93), so after writing, the justification is read back and compared with what should be there (the one you passed, or the one the layer had). If it moved it is written again on its own; if it still disagrees the call throws naming expected and actual rather than reporting a success the layer does not show. The result carries `justification` (the name read back) and `justificationReasserted` (true when the second write was needed).",
2063
2463
  add_text_animator: "Add a text animator group (position/scale/rotation/opacity/tracking/skew/fillColor/strokeColor). Optional range selector.",
2064
2464
  // ---------- shapes ----------
2065
2465
  set_shape_path: "Replace a shape path with new vertices/tangents/closed.",
@@ -2076,53 +2476,55 @@ var descriptions = {
2076
2476
  screenshot_frame: "ONE-OFF visual check of a comp. Base64 PNG. Use only at key moments \u2014 never per-frame or in a loop. To judge MOTION, pass `times` (2-6 values) and get one tiled contact sheet with the time burned into each tile: one call, one image, about the pixel cost of a single frame \u2014 always cheaper and safer than several separate calls. `time` and `times` are mutually exclusive. Confirm the numbers with get_layer_full / get_keyframes; a picture is not exact. A downsample is chosen from the comp size (per tile, for a sheet) unless you pass one \u2014 omit it, and pass downsample:1 only when you genuinely need full resolution. Not every result is an image: 'Stale frame' means AE re-served an earlier render, 'Corrupt frame' means the file AE wrote is not a whole PNG (retry at downsample 6-8, or screenshot the precomps separately), 'Render timed out' means it is still rendering (wait, don't retry immediately), and `empty:true` means every pixel is transparent \u2014 check the time, in/out points and enabled state. On a sheet, a bad tile is drawn as a marked block and named in `warning`; the rest of the sheet is still good. Never disable layers to make a frame render.",
2077
2477
  screenshot_layer: "ONE-OFF visual check of a single layer (solo'd) at a time. Same one-off rule and same downsample guidance as screenshot_frame. The same 'Stale frame', 'Corrupt frame', 'Render timed out' and `empty:true` non-image results apply. No `times` here \u2014 contact sheets are screenshot_frame only.",
2078
2478
  // ---------- batch ----------
2079
- run_batch: 'Many ops in one ExtendScript pass \u2014 far faster than the same ops as separate calls, and far fewer undo steps. **Up to 500 ops it is exactly one undo step.** Over 500 it is chunked into a background job (returns a jobId, streams progress, finish with await_job) and lands as **one undo step per chunk of 25** \u2014 around 24 steps for 600 ops \u2014 because After Effects discards an undo group that spans two script calls. Every result reports the measured count in `undoSteps` with a `note`: read it before you tell anyone how to undo the work, and never say "one Cmd-Z" for a chunked batch. `singleUndo:true` forces one undo step at any size up to 2000 ops, by running the whole batch in one blocking call \u2014 After Effects\' interface is frozen for the duration and no progress is reported, so use it only when a single Cmd-Z actually matters to the user. Over 2000 it is refused rather than freezing AE. transactional:true (default) stops at the first error; nothing rolls back either way \u2014 the ops before it stay applied, so read the state back rather than re-running. `diff:true` appends a structural diff of the comps the batch touched \u2014 what it added, retimed, re-parented and keyframed \u2014 and on a failure that diff rides on the error, which is the cheapest way to find where a half-applied batch stopped.',
2479
+ run_batch: 'Many ops in one ExtendScript pass \u2014 far faster than the same ops as separate calls, and far fewer undo steps. **Up to 500 ops it is exactly one undo step.** Over 500 it is chunked into a background job \u2014 this call returns a jobId at once, before the first chunk runs; finish with await_job, which blocks for the job and is where its progress is delivered (pass a progressToken on THAT call, not on this one; get_job polls without one) \u2014 and lands as **one undo step per chunk of 25** \u2014 around 24 steps for 600 ops \u2014 because After Effects discards an undo group that spans two script calls. Every result reports the measured count in `undoSteps` with a `note`: read it before you tell anyone how to undo the work, and never say "one Cmd-Z" for a chunked batch. `singleUndo:true` forces one undo step at any size up to 2000 ops, by running the whole batch in one blocking call \u2014 After Effects\' interface is frozen for the duration and no progress is reported, so use it only when a single Cmd-Z actually matters to the user. Over 2000 it is refused rather than freezing AE. transactional:true (default) stops at the first error; nothing rolls back either way \u2014 the ops before it stay applied, so read the state back rather than re-running. `diff:true` appends a structural diff of the comps the batch touched \u2014 what it added, retimed, re-parented and keyframed \u2014 and on a failure that diff rides on the error, which is the cheapest way to find where a half-applied batch stopped.',
2080
2480
  // ---------- explore ----------
2081
2481
  get_project_summary: "Project state: path, item count, active item, flat item list with type (comp | footage | solid | folder | unknown \u2014 same vocabulary as a layer's sourceType).",
2082
- find_layers: "Search across one or all comps for layers matching name/type/effect filters.",
2482
+ find_layers: "Search one comp (`compId`) or every comp for layers matching name (regex, case-insensitive), type and/or effect matchName filters. Returns `{matches, count, compsSearched, included}`; each match is id/index/name/sourceType plus compId/compName \u2014 enough to address the layer with any other tool. That is the whole record by default: pass `include` (flags, timing, parent \u2014 the same names as list_layers) to add sections to every match, and note that unlike list_layers an omitted `include` here means the bounded form, not everything. For one layer's details use get_layer_full on the id it gives you.",
2083
2483
  // ---------- raw ----------
2084
2484
  run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. Pass exactly one of `code` (inline) or `scriptPath` (absolute) to run a .jsx file the server reads \u2014 use the path for anything long, so the script never enters the conversation. `libraries` (absolute .jsx paths) are read by the server too and inlined ahead of the script in the same scope, so their functions are callable from it; they are re-evaluated on every call, so keep them to declarations rather than to work. Keep shared helpers there instead of pasting them into every script. In scope: `app`, the full `OPS` table (every tool on this server, e.g. `OPS.set_transform({compId, layerId, position:[0,0]})`), and these helpers \u2014 `compById(id)`, `getCompById(id)`, `layerById(compOrId, layerId)`, `getLayerById(comp, layerId)`, `walkProperty(layer, ["Transform","Position"])`, `addKeys(prop, [[t, v], \u2026])` \u2192 key indices, `ease(prop, keyIndex, easeIn, easeOut)` which sizes the KeyframeEase array itself (a bare number means influence; omit easeOut for the same both sides), `shape(comp, {name, position})` which lands the layer at [0,0] rather than the comp centre, and `withoutUndoGroup(fn)`. `app.executeCommand()` menu commands silently no-op here \u2014 they depend on host focus and the active selection, neither of which this bridge has \u2014 so use the API equivalents (`CompItem.duplicate()`, `layer.duplicate()`). `return X` sends a value back \u2014 arrays and nested objects come back whole. Anything that cannot be JSON is replaced in place by a marker string, never dropped: `"[function]"`, `"[undefined]"`, `"[circular]"`, `"[max depth]"`, `"[NaN]"`, and live AE objects as `"[CompItem \\"Main\\" #12]"` \u2014 a handle to pass to a real read tool, not a walk of the object. An empty result therefore means the script really returned nothing. A script with no explicit `return` \u2014 including one ending in a bare expression, which does NOT return its value \u2014 comes back as `{ok:true, returned:null, undoGroup, note}`. That means it ran to completion; it did **not** fail, so do not re-run it. Nothing rolls back, so re-running a mutating script applies it twice. A failure names the line of YOUR script and prints its text; when the number cannot be mapped it says so rather than guessing. Everything before that line already ran and nothing rolls back, so read the state back \u2014 never re-run the script to see if it fails again. AE refuses copyToComp for a layer with a parent or a linked expression while an undo group is open: call `withoutUndoGroup(function(){ \u2026 })` around just that part, or pass undoGroup:false for the whole script (its changes then land as whatever undo steps AE records on its own, not one). Keep loops short \u2014 ExtendScript is single-threaded and freezes the user\'s UI. `diff:true` fingerprints the comp before and after the script and appends only what changed, so the script reports what it actually did instead of you reading the comp back; with it the answer is always `{ok, returned, diff}`. If the script throws, that diff is appended to the error \u2014 which is how you find where a half-applied script stopped, since nothing rolls back.',
2085
2485
  // ---------- footage ----------
2086
2486
  import_footage: "Import a file (video, image, audio, SVG, PSD/AI) into the project. Returns the item id \u2014 pass it to create_footage_layer to place it. Validates what AE actually produced: an SVG whose viewBox asks for one aspect ratio and imports at another is a known AE bug that renders as nothing with no error, so the item is deleted and the call throws with the workaround. `force:true` keeps it and reports the problem in `validation` instead.",
2087
2487
  create_footage_layer: "Place an imported project item into a comp as a layer. Takes the itemId from import_footage or get_project_summary. For a comp use create_precomp_layer instead.",
2488
+ purge_unused_footage: "Remove footage items no comp uses. By default only solids \u2014 the items behind solid and adjustment layers, which delete_comp and delete_layer leave behind in the Solids folder, so a session that builds and discards comps can leave hundreds. `solidsOnly:false` removes every unused footage item, imported files and placeholders too, which is what AE's own Remove Unused Footage does. Comps and folders are never touched: a nested comp nothing uses is not footage. `dryRun:true` lists what would go (`wouldRemove`) without removing anything or adding an undo step \u2014 use it first on a project you did not build. `folderId` limits the sweep to one folder and its subfolders. Reports `scanned`, `inUse` and `removed` (id, name, kind); a real run is one undo step. If one removal fails, the error names it, what was already removed and what was not attempted, since nothing rolls back.",
2088
2489
  // ---------- audio ----------
2089
- place_audio_cues: "Score a scene in one call: a list of cues, each a sound at a comp time with a level in dB, becomes one audio layer each \u2014 imported if needed, named, trimmed, labelled \u2014 in a single undo step. Reach for it whenever you are placing more than two or three sound effects; the alternative is dozens of round trips or a run_jsx loop that has to know that `layer.property('ADBE Audio Levels')` returns null on an audio layer. Every cue names its sound with **exactly one** of `path` (a file to use \u2014 imported once however many cues name it, and an item already in the project from that path is reused) or `footageId` (one already imported); both together, or neither, is reported against that cue's index with nothing placed. `time` is when the cue starts; `levelDb` is decibels (0 = the file as recorded, negative = quieter), defaulting to 0. `inPoint`/`outPoint` trim in COMP time, not file time. It is all-or-nothing: every cue is validated \u2014 file exists, item has audio, time inside the comp \u2014 before a single layer is made, and if a later one still fails, everything this call created is removed and the error names the cue. Use `dryRun:true` to check a cue list against the project without touching anything, including the undo stack; it reports which paths do not exist. Max 200 cues per call.",
2490
+ place_audio_cues: "Score a scene in one call: a list of cues, each a sound at a comp time with a level in dB, becomes one audio layer each \u2014 imported if needed, named, trimmed, labelled \u2014 in a single undo step. Reach for it whenever you are placing more than two or three sound effects; the alternative is dozens of round trips or a run_jsx loop that has to know that `layer.property('ADBE Audio Levels')` returns null on an audio layer. Every cue names its sound with **exactly one** of `path` (a file to use \u2014 imported once however many cues name it, and an item already in the project from that path is reused) or `footageId` (one already imported); both together, or neither, is reported against that cue's index with nothing placed. `time` is when the cue starts; `levelDb` is decibels (0 = the file as recorded, negative = quieter), defaulting to 0. `inPoint`/`outPoint` trim in COMP time, not file time. Three per-cue options cover what scoring passes otherwise redo in run_jsx: `loop:true` repeats the sound to `outPoint` or the comp's end (time remapping plus a wrap-around expression on Time Remap; AE's own remap keyframes are kept, since removing them breaks the property); `fadeIn`/`fadeOut` in seconds put Audio Levels keyframes at the layer's real in/out, fading between `fadeFloorDb` (call-level, default -48) and `levelDb`; `stretch` is a percentage (100 unchanged, 200 half speed and lower pitch) and the start time and trims are re-asserted after it. A loop that is also stretched has the factor baked into its expression, so changing stretch by hand later leaves the loop at the old rate. A fade longer than the cue's placed length, a stretch of 0 or below, or a floor at or above the cue's level is refused by cue index with nothing placed; for a file not yet imported the fade is checked right after the import, before any layer exists. It is all-or-nothing: every cue is validated \u2014 file exists, item has audio, time inside the comp, options sane \u2014 before a single layer is made, and if a later one still fails, everything this call created is removed and the error names the cue. The result carries one small entry per cue (layerId, name, time, plus `looped`/`stretch`/`fadeIn`/`fadeOut` and read-back in/out only where an option set them) and `sources` naming what was imported or reused \u2014 not an echo of the request. Use `dryRun:true` to check a cue list against the project without touching anything, including the undo stack; it answers with counts, `wouldImport`, `wouldReuse` and the failing cues by index, never the whole resolved list. Max 200 cues per call.",
2090
2491
  // ---------- motion graphics templates ----------
2091
2492
  export_mogrt: "Export a comp as a .mogrt for Premiere. Handles the three things that make a scripted export look like a hung connection: it saves the project first (removes AE's modal save prompt), suppresses the modal font warning that otherwise freezes this connection until someone clicks OK in AE, and runs outside the undo group. **The comp needs at least one property in its Essential Graphics panel** \u2014 After Effects will not build a template from an empty one, and it refuses silently, so this tool checks the controller count first and refuses before exporting rather than reporting a cause it cannot know. Fix it in AE: Window > Essential Graphics, pick the comp, drag a layer property in. `name` defaults to the comp name \u2014 AE's own default is the literal 'Untitled', so every export would otherwise overwrite the same file. Pass `posterTime` to render that frame as the template's thumbnail, replacing the black one AE writes; the export still succeeds if only the thumbnail fails. Needs the project saved once by hand first. `fonts` in the result lists the fonts the template will require \u2014 tell the user, since non-Adobe ones make Premiere flag the template. If an export still fails, read the message before acting: it lists what was checked, and it only names a modal dialog when dialogs were left unsuppressed. Under the default suppression a dialog is impossible, the cause is genuinely unknown, and the way to see AE's own reason is for the user to run the same export by hand from the Essential Graphics panel.",
2092
2493
  // ---------- house style ----------
2093
2494
  get_house_style: "The user's palette, type, motion and layout defaults for the project that is open, read from `house-style.md` beside the .aep. Call it once before building anything so your work matches the rest of theirs. Returns a few-hundred-token summary by default \u2014 palette as named hexes, type, motion, layout, and a note naming anything it could not summarise; pass `detail:'full'` for the whole document, which you need before editing the guide with set_house_style. `found:false` means none exists yet \u2014 build with sensible defaults and offer to capture one afterwards. Cheap; never a reason to skip.",
2094
2495
  set_house_style: "Write the project's style guide. Replaces the whole file, so read it first and send the merged document \u2014 `overwrite:true` is required to replace an existing one. The project must have been saved at least once, since the file lives beside the .aep. Use the style-guide topic of ae_guide for how to capture a style worth writing down.",
2095
2496
  // ---------- guidance ----------
2096
- ae_guide: "The full working guidance for these tools, by topic. Read `after-effects` before a first substantial build in a session, `style-guide` when capturing or editing the user's look, `ae-setup` when a tool cannot reach After Effects. Covers the traps that silently produce wrong output and are not visible from any single tool's schema.",
2497
+ ae_guide: "The full working guidance for these tools, by topic. Read `after-effects` before a first substantial build in a session, `style-guide` when capturing or editing the user's look, `ae-setup` when a tool cannot reach After Effects, `whats-new` when a call behaves differently from what you expected. Covers the traps that silently produce wrong output and are not visible from any single tool's schema. `whats-new` is one section per release, newest first, each entry leading with the rule as it now stands and a `supersedes:` line quoting the old rule a project's notes may still carry; pass `since` (a version, e.g. '0.4.0') to get only the releases after it, or one line saying nothing is newer \u2014 that is how the absorb-release prompt brings a project's docs up to date once per upgrade. The whats-new answer always opens with this server's own version. `since` is rejected on any other topic.",
2097
2498
  // ---------- jobs ----------
2098
- await_job: "Block until job is done. Default 10min timeout. Returns the same payload the tool would have.",
2099
- get_job: "Non-blocking job status: progress/total/state/error.",
2499
+ await_job: "Block until a chunked run_batch job is done. Default 10min timeout. Returns the same payload the tool would have, `undoSteps` included. **This is the call that carries progress**: send it with a progressToken and notifications/progress arrive while it waits, every one before its response. run_batch's own response is back before the first chunk runs, so no progress can ride on that call \u2014 a token there is ignored.",
2500
+ get_job: "Non-blocking job status: progress/total/state/error. Poll this when you cannot keep an await_job open. It never sends notifications/progress \u2014 that is await_job's job.",
2100
2501
  cancel_job: "Set cancel flag; chunked loop stops at next boundary.",
2101
2502
  // ---------- setup ----------
2102
- check_setup: "Diagnose the After Effects connection: panel installed, up to date, the version AE is actually running, Adobe debug preference on, AE running, bridge answering. Read-only and safe to call any time. Call this FIRST whenever another tool reports it cannot reach After Effects or says the panel is out of date, then relay `nextSteps` to the user in plain language. `panelRunningCurrent` is the one that predicts whether calls will work \u2014 it can fail while `panelUpToDate` passes, which means an update is installed but AE has not been restarted.",
2103
- setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing, out of date, or older than what AE is running. It writes to the user's Adobe CEP extensions folder and sets a user-level Adobe preference \u2014 tell the user what it will do before calling it. Prefer running it while AE is CLOSED: the panel then loads when they open it, with no restart. If AE is already open they must quit and reopen it, and until they do, the old panel keeps answering. If the preference was newly enabled, a one-time Mac reboot may also be needed.",
2104
- init_project: "Set up a working folder for one video, series or client: a project brief and a pointer to the house style, written in whichever layout this client reads. Run it when the user is starting out or asks to set up a project. It writes files to disk \u2014 say which folder before calling, and pass `dir` explicitly unless the client already told the server where it is working. It never overwrites anything and reports every path it wrote.",
2503
+ check_setup: "Diagnose the After Effects connection: panel installed, up to date, the version AE is actually running, Adobe debug preference on, AE running, bridge answering \u2014 on whichever port it answers, since the port file is only a hint \u2014 plus `portAgreement` (do tool calls go to the port the panel answers on?) and, when nothing answers at all, `panelSignature` (did CEP's own log say it refused to load the panel?). Read-only and safe to call any time. Call this FIRST whenever another tool reports it cannot reach After Effects or says the panel is out of date, then relay `nextSteps` to the user in plain language. `panelRunningCurrent` is the one that predicts whether calls will work \u2014 it can fail while `panelUpToDate` passes, which means an update is installed but AE has not been restarted. A failing `portAgreement` means the server's port went stale, not the panel: retry the call (the server re-discovers the port whenever a call is refused) or reconnect the MCP server \u2014 never restart After Effects for it, a panel is listening. A failing `panelSignature` means no restart and no reinstall will help; `nextSteps` carries the self-signing fix.",
2504
+ setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing, out of date, or older than what AE is running. It writes to the user's Adobe CEP extensions folder and sets two user-level Adobe preferences \u2014 PlayerDebugMode, and CEP's LogLevel where it was never set, so that if AE ever refuses to load the panel its CEP log says why \u2014 tell the user what it will do before calling it. Prefer running it while AE is CLOSED: the panel then loads when they open it, with no restart. If AE is already open they must quit and reopen it, and until they do, the old panel keeps answering. If the preference was newly enabled, a one-time Mac reboot may also be needed. If the existing install had been self-signed (check_setup's `panelInstalled` says so), the fresh copy is unsigned and the result says the signing has to be redone \u2014 on a machine that needed signing, tell the user before calling this.",
2505
+ init_project: "Set up a working folder for one video, series or client: a project brief and a pointer to the house style, written in whichever layout this client reads. Run it when the user is starting out or asks to set up a project. It writes files to disk \u2014 say which folder before calling, and pass `dir` explicitly unless the client already told the server where it is working. It never overwrites anything and reports every path it wrote. AGENTS.md carries a `Tools version last absorbed: <version>` line stamped with this server's version; the absorb-release prompt reads it after an upgrade to apply only what changed since, then moves it on \u2014 leave the line in place.",
2105
2506
  // ---------- issue journal ----------
2106
- list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked. Read it when a tool fails in a way you don't immediately understand \u2014 pass `tool` or `query` to narrow it \u2014 and before nontrivial work. It can save you rediscovering a fix that already cost someone an hour. Merges two journals and tags every entry with the one it came from: `project` (or `home`, the fallback when there is no project folder) for this project's own notes, `user` for tool and After Effects behaviour carried across every project. Returns a one-line index by default; the cause and the workaround are in the entry, so follow up with `id` on anything that looks like your problem \u2014 ids are unique only within a journal, so use the `scope:id` form the `next` pointer shows. `detail:'full'` dumps every matching entry and is rarely worth it. Also returns the repo and server version needed to report one.",
2107
- log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes. Call list_known_issues first and reuse the same title to extend an existing entry rather than duplicating it. Choose the scope by what the entry is *about*: leave it at the default `project` for this project's footage, comps or files, and pass `scope:'user'` when it is about how these tools or After Effects behave, so the next project starts already knowing it. If the result comes back with reported:false, then AFTER you have finished the actual work, close your reply by telling the user in plain language that something took much longer than it should have and offering to pass it to the people who maintain this tool \u2014 phrase it for a motion designer, in terms of what actually happened, and don't say 'GitHub issue' or 'bug report' unless they say it first.",
2108
- mark_issue_reported: "Record that a journal entry has been sent to the maintainers, with the resulting URL. Call it only once the issue really exists, so later sessions don't ask the user to report the same thing twice."
2507
+ list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked. You do not need to read this ahead of time: a failed call names the entries that match its tool and error text in the error itself, under `Known from earlier sessions:`. Call this when you are planning something that has bitten before (pass `tool` or `query`), when a failure's pointer names an entry you want to open, or when the user asks what is known. Merges two journals and tags every entry with the one it came from: `project` (or `home`, the fallback when there is no project folder) for this project's own notes, `user` for tool and After Effects behaviour carried across every project. Returns a one-line index by default, entries naming `tool` first and then most recently seen first; the cause and the workaround are in the entry, so follow up with `id` on anything that looks like your problem \u2014 ids are unique only within a journal, so use the `scope:id` form the `next` pointer shows. Archived entries (not seen for 30 days, last seen on an older server, or retired with archive_issue) are hidden and counted in `archivedCount`; `includeArchived:true` lists them, and an `id` read always works. `detail:'full'` dumps every matching entry and is rarely worth it. Also returns the repo and server version needed to report one.",
2508
+ log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes. Pass `tools` and the exact `errorText`: that is what lets a later failure be answered with this entry, and it is what merges a re-log into the existing entry even under a different title (the result says `mergedBy` and keeps the existing title). Reusing an existing title extends it too. Pass `kind:'ae-quirk'` for a permanent After Effects behaviour, so a new release does not archive it as fixed; the default `tool-bug` is presumed fixed once it was last seen on an older server than the one running. Choose the scope by what the entry is *about*: leave it at the default `project` for this project's footage, comps or files, and pass `scope:'user'` when it is about how these tools or After Effects behave, so the next project starts already knowing it. If the result says `reopened:true`, an entry that had been retired is back \u2014 tell the user the problem was supposed to be gone. If the result comes back with reported:false, then AFTER you have finished the actual work, close your reply by telling the user in plain language that something took much longer than it should have and offering to pass it to the people who maintain this tool \u2014 phrase it for a motion designer, in terms of what actually happened, and don't say 'GitHub issue' or 'bug report' unless they say it first.",
2509
+ mark_issue_reported: "Record that a journal entry has been sent to the maintainers, with the resulting URL. Call it only once the issue really exists, so later sessions don't ask the user to report the same thing twice.",
2510
+ archive_issue: "Retire a journal entry for good: it leaves the index (and the pointers a failed call shows still name it, marked archived with this reason) but the file stays, hand-editable, with `reason` in its frontmatter. Use it when a report already covers the problem (pass that URL as the reason), when a release fixed it, or when the lesson has been moved into the project's own notes. Entries that merely go quiet archive themselves \u2014 30 days unseen, or last seen on an older server \u2014 so this is for a decision, not for tidying. Use the scope-qualified id (`user:my-entry`) when both journals hold the slug; only the one named moves. A later log_issue with the same title or the same tool and error text brings it back and says so."
2109
2511
  };
2110
2512
 
2111
2513
  // src/tools/runJsxSource.ts
2112
- import fs3 from "node:fs";
2113
- import path4 from "node:path";
2514
+ import fs4 from "node:fs";
2515
+ import path5 from "node:path";
2114
2516
  var MAX_SCRIPT_BYTES = 512 * 1024;
2115
2517
  var MAX_LIBRARIES = 16;
2116
2518
  var MAX_TOTAL_BYTES = 1024 * 1024;
2117
2519
  function readScriptFile(p, what) {
2118
- if (!path4.isAbsolute(p)) {
2520
+ if (!path5.isAbsolute(p)) {
2119
2521
  throw new Error(
2120
2522
  `${what} must be an absolute path \u2014 got "${p}". Relative paths have no meaning here: the server resolves them against its own working directory, which is not the user's project folder (Claude Desktop starts it at "/").`
2121
2523
  );
2122
2524
  }
2123
2525
  let stat;
2124
2526
  try {
2125
- stat = fs3.statSync(p);
2527
+ stat = fs4.statSync(p);
2126
2528
  } catch (e) {
2127
2529
  throw new Error(`${what} could not be read: ${p} (${e.code ?? e.message}).`);
2128
2530
  }
@@ -2135,7 +2537,7 @@ function readScriptFile(p, what) {
2135
2537
  }
2136
2538
  let text;
2137
2539
  try {
2138
- text = fs3.readFileSync(p, "utf8");
2540
+ text = fs4.readFileSync(p, "utf8");
2139
2541
  } catch (e) {
2140
2542
  throw new Error(`${what} could not be read: ${p} (${e.message}).`);
2141
2543
  }
@@ -2165,157 +2567,36 @@ function resolveRunJsxSource(args) {
2165
2567
  out.code = args.code;
2166
2568
  delete out.scriptPath;
2167
2569
  }
2168
- if (requestedLibraries && requestedLibraries.length > 0) {
2169
- if (requestedLibraries.length > MAX_LIBRARIES) {
2170
- throw new Error(
2171
- `run_jsx takes at most ${MAX_LIBRARIES} libraries, got ${requestedLibraries.length}.`
2172
- );
2173
- }
2174
- const libs = [];
2175
- const seen = /* @__PURE__ */ new Set();
2176
- for (const raw of requestedLibraries) {
2177
- if (seen.has(raw)) continue;
2178
- seen.add(raw);
2179
- const { text, bytes } = readScriptFile(raw, `run_jsx library "${raw}"`);
2180
- libs.push({ path: raw, text, bytes });
2181
- }
2182
- out.libraries = libs;
2183
- }
2184
- const total = Buffer.byteLength(out.code, "utf8") + (out.libraries ?? []).reduce((n, l) => n + l.bytes, 0);
2185
- if (total > MAX_TOTAL_BYTES) {
2186
- throw new Error(
2187
- `run_jsx would send ${total} bytes of source (script plus ${out.libraries?.length ?? 0} libraries), over the ${MAX_TOTAL_BYTES}-byte limit for one call. Libraries are inlined ahead of the script \u2014 they have to be, or their functions are not in its scope \u2014 so every byte travels on every call. Pass only the libraries this script actually uses.`
2188
- );
2189
- }
2190
- return out;
2191
- }
2192
-
2193
- // src/setup/check.ts
2194
- import crypto2 from "node:crypto";
2195
- import fs6 from "node:fs";
2196
- import path7 from "node:path";
2197
-
2198
- // src/setup/paths.ts
2199
- import fs4 from "node:fs";
2200
- import os3 from "node:os";
2201
- import path5 from "node:path";
2202
- import { createRequire } from "node:module";
2203
- import { fileURLToPath } from "node:url";
2204
- var BUNDLE_ID = "games.engine-room.ae-mcp";
2205
- function isSupportedPlatform() {
2206
- return process.platform === "darwin" || process.platform === "win32";
2207
- }
2208
- function packageRoot() {
2209
- let dir = path5.dirname(fileURLToPath(import.meta.url));
2210
- for (let i = 0; i < 8; i++) {
2211
- if (fs4.existsSync(path5.join(dir, "package.json"))) return dir;
2212
- const parent = path5.dirname(dir);
2213
- if (parent === dir) break;
2214
- dir = parent;
2215
- }
2216
- return path5.dirname(fileURLToPath(import.meta.url));
2217
- }
2218
- function executableDir() {
2219
- return path5.dirname(process.execPath);
2220
- }
2221
- function packageVersion() {
2222
- for (const dir of [packageRoot(), executableDir()]) {
2223
- try {
2224
- const pkg = JSON.parse(fs4.readFileSync(path5.join(dir, "package.json"), "utf8"));
2225
- if (typeof pkg.version === "string") return pkg.version;
2226
- } catch {
2227
- }
2228
- }
2229
- return "unknown";
2230
- }
2231
- function panelSourceDir() {
2232
- const candidates = [
2233
- // The live workspace copy comes first so a git checkout always installs
2234
- // what the developer is editing, never a stale vendored copy left behind by
2235
- // a previous `npm pack`. Only the second path exists in the tarball.
2236
- path5.resolve(packageRoot(), "..", "ae-panel"),
2237
- path5.join(packageRoot(), "panel"),
2238
- // Compiled single-file build: the panel ships beside the executable.
2239
- path5.join(executableDir(), "panel")
2240
- ];
2241
- for (const dir of candidates) {
2242
- if (fs4.existsSync(path5.join(dir, "CSXS", "manifest.xml"))) return dir;
2243
- }
2244
- return null;
2245
- }
2246
- function cepExtensionsDir() {
2247
- if (process.platform === "win32") {
2248
- const appData = process.env.APPDATA ?? path5.join(os3.homedir(), "AppData", "Roaming");
2249
- return path5.join(appData, "Adobe", "CEP", "extensions");
2250
- }
2251
- return path5.join(os3.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
2252
- }
2253
- function installedPanelDir() {
2254
- return path5.join(cepExtensionsDir(), BUNDLE_ID);
2255
- }
2256
- function isWsModuleDir(dir) {
2257
- try {
2258
- const pkg = JSON.parse(fs4.readFileSync(path5.join(dir, "package.json"), "utf8"));
2259
- if (pkg.name !== "ws") return false;
2260
- return fs4.existsSync(path5.join(dir, "index.js")) && fs4.existsSync(path5.join(dir, "lib", "websocket.js"));
2261
- } catch {
2262
- return false;
2263
- }
2264
- }
2265
- function wsModuleDir() {
2266
- const candidates = [];
2267
- try {
2268
- const require2 = createRequire(import.meta.url);
2269
- const entry = require2.resolve("ws");
2270
- if (path5.isAbsolute(entry)) {
2271
- const marker = `${path5.sep}node_modules${path5.sep}ws${path5.sep}`;
2272
- const idx = entry.lastIndexOf(marker);
2273
- candidates.push(idx >= 0 ? entry.slice(0, idx + marker.length - 1) : path5.dirname(entry));
2274
- }
2275
- } catch {
2276
- }
2277
- candidates.push(path5.join(executableDir(), "node_modules", "ws"));
2278
- candidates.push(path5.join(packageRoot(), "node_modules", "ws"));
2279
- for (const dir of candidates) {
2280
- if (isWsModuleDir(dir)) return dir;
2281
- }
2282
- return null;
2283
- }
2284
- function listPanelFiles(dir, prefix = "") {
2285
- const out = [];
2286
- for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
2287
- if (entry.name === "node_modules" || entry.name === ".DS_Store") continue;
2288
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
2289
- if (entry.isDirectory()) out.push(...listPanelFiles(path5.join(dir, entry.name), rel));
2290
- else out.push(rel);
2291
- }
2292
- return out;
2293
- }
2294
- function sameContents(a, b) {
2295
- try {
2296
- if (fs4.statSync(a).size !== fs4.statSync(b).size) return false;
2297
- return fs4.readFileSync(a).equals(fs4.readFileSync(b));
2298
- } catch {
2299
- return false;
2300
- }
2301
- }
2302
- function panelInstallDiff(source, installed) {
2303
- return listPanelFiles(source).filter((rel) => !sameContents(path5.join(source, rel), path5.join(installed, rel))).sort();
2304
- }
2305
- function copyRecursive(src, dst) {
2306
- const stat = fs4.lstatSync(src);
2307
- if (stat.isDirectory()) {
2308
- fs4.mkdirSync(dst, { recursive: true });
2309
- for (const entry of fs4.readdirSync(src)) {
2310
- copyRecursive(path5.join(src, entry), path5.join(dst, entry));
2570
+ if (requestedLibraries && requestedLibraries.length > 0) {
2571
+ if (requestedLibraries.length > MAX_LIBRARIES) {
2572
+ throw new Error(
2573
+ `run_jsx takes at most ${MAX_LIBRARIES} libraries, got ${requestedLibraries.length}.`
2574
+ );
2311
2575
  }
2312
- } else if (stat.isSymbolicLink()) {
2313
- fs4.symlinkSync(fs4.readlinkSync(src), dst);
2314
- } else {
2315
- fs4.copyFileSync(src, dst);
2576
+ const libs = [];
2577
+ const seen = /* @__PURE__ */ new Set();
2578
+ for (const raw of requestedLibraries) {
2579
+ if (seen.has(raw)) continue;
2580
+ seen.add(raw);
2581
+ const { text, bytes } = readScriptFile(raw, `run_jsx library "${raw}"`);
2582
+ libs.push({ path: raw, text, bytes });
2583
+ }
2584
+ out.libraries = libs;
2316
2585
  }
2586
+ const total = Buffer.byteLength(out.code, "utf8") + (out.libraries ?? []).reduce((n, l) => n + l.bytes, 0);
2587
+ if (total > MAX_TOTAL_BYTES) {
2588
+ throw new Error(
2589
+ `run_jsx would send ${total} bytes of source (script plus ${out.libraries?.length ?? 0} libraries), over the ${MAX_TOTAL_BYTES}-byte limit for one call. Libraries are inlined ahead of the script \u2014 they have to be, or their functions are not in its scope \u2014 so every byte travels on every call. Pass only the libraries this script actually uses.`
2590
+ );
2591
+ }
2592
+ return out;
2317
2593
  }
2318
2594
 
2595
+ // src/setup/check.ts
2596
+ import crypto2 from "node:crypto";
2597
+ import fs7 from "node:fs";
2598
+ import path8 from "node:path";
2599
+
2319
2600
  // src/setup/panelVersion.ts
2320
2601
  import crypto from "node:crypto";
2321
2602
  import fs5 from "node:fs";
@@ -2372,6 +2653,9 @@ function unknownOpMessage(op) {
2372
2653
 
2373
2654
  // src/setup/platform.ts
2374
2655
  import { execFile } from "node:child_process";
2656
+ import fs6 from "node:fs";
2657
+ import os4 from "node:os";
2658
+ import path7 from "node:path";
2375
2659
  import { promisify } from "node:util";
2376
2660
  var exec = promisify(execFile);
2377
2661
  var CSXS_VERSIONS = [12, 11, 10, 9];
@@ -2446,37 +2730,153 @@ async function isAfterEffectsRunning() {
2446
2730
  function debugModeLocation() {
2447
2731
  return process.platform === "win32" ? "the PlayerDebugMode value under HKEY_CURRENT_USER\\Software\\Adobe\\CSXS.*" : "Adobe's PlayerDebugMode preference";
2448
2732
  }
2733
+ function cepLogDir() {
2734
+ if (process.platform === "win32") {
2735
+ return process.env.TEMP ?? process.env.TMP ?? os4.tmpdir();
2736
+ }
2737
+ return path7.join(os4.homedir(), "Library", "Logs", "CSXS");
2738
+ }
2739
+ var CEP_LOG_NAME = /^(?:cep|csxs)(\d+)-AEFT\.log$/i;
2740
+ function cepLogPaths(dir = cepLogDir()) {
2741
+ let names;
2742
+ try {
2743
+ names = fs6.readdirSync(dir);
2744
+ } catch {
2745
+ return [];
2746
+ }
2747
+ return names.filter((n) => CEP_LOG_NAME.test(n)).map((n) => {
2748
+ const file = path7.join(dir, n);
2749
+ let mtime = 0;
2750
+ try {
2751
+ mtime = fs6.statSync(file).mtimeMs;
2752
+ } catch {
2753
+ }
2754
+ return { file, mtime };
2755
+ }).sort((a, b) => b.mtime - a.mtime).map((e) => e.file);
2756
+ }
2757
+ async function cepLogLevel(version) {
2758
+ try {
2759
+ if (process.platform === "win32") {
2760
+ const { stdout: stdout2 } = await exec("reg", ["query", `HKCU\\Software\\Adobe\\CSXS.${version}`, "/v", "LogLevel"]);
2761
+ const m = /LogLevel\s+REG_\w+\s+(\d+)/.exec(stdout2);
2762
+ return m ? Number(m[1]) : null;
2763
+ }
2764
+ const { stdout } = await exec("defaults", ["read", `com.adobe.CSXS.${version}`, "LogLevel"]);
2765
+ const n = Number(stdout.trim());
2766
+ return Number.isFinite(n) ? n : null;
2767
+ } catch {
2768
+ return null;
2769
+ }
2770
+ }
2771
+ var CEP_LOG_LEVEL = 6;
2772
+ async function ensureCepLogging() {
2773
+ const set = [];
2774
+ const kept = [];
2775
+ for (const v of CSXS_VERSIONS) {
2776
+ const current = await cepLogLevel(v);
2777
+ if (current !== null) {
2778
+ kept.push({ version: v, level: current });
2779
+ continue;
2780
+ }
2781
+ try {
2782
+ if (process.platform === "win32") {
2783
+ await exec("reg", [
2784
+ "add",
2785
+ `HKCU\\Software\\Adobe\\CSXS.${v}`,
2786
+ "/v",
2787
+ "LogLevel",
2788
+ "/t",
2789
+ "REG_SZ",
2790
+ "/d",
2791
+ String(CEP_LOG_LEVEL),
2792
+ "/f"
2793
+ ]);
2794
+ } else {
2795
+ await exec("defaults", ["write", `com.adobe.CSXS.${v}`, "LogLevel", String(CEP_LOG_LEVEL)]);
2796
+ }
2797
+ set.push(v);
2798
+ } catch {
2799
+ }
2800
+ }
2801
+ return { set, kept };
2802
+ }
2803
+ function cepLogLevelCommand(version = CSXS_VERSIONS[0]) {
2804
+ return process.platform === "win32" ? `reg add HKCU\\Software\\Adobe\\CSXS.${version} /v LogLevel /t REG_SZ /d ${CEP_LOG_LEVEL} /f` : `defaults write com.adobe.CSXS.${version} LogLevel ${CEP_LOG_LEVEL}`;
2805
+ }
2449
2806
 
2450
2807
  // src/setup/check.ts
2451
2808
  var BRIDGE_PROBE_MS = 2e3;
2452
2809
  var BRIDGE_BUSY_FIX = "This is a timeout, not a refused connection \u2014 something is listening, it just did not answer in time. After Effects is most likely busy running a script, or waiting on a modal dialog nobody has clicked. Wait and run check_setup again, up to about a minute, before restarting anything; it usually clears on its own. On macOS, if the user has switched to another desktop, ask them to switch back to the one After Effects is on.";
2453
2810
  function sha256(file) {
2454
2811
  try {
2455
- return crypto2.createHash("sha256").update(fs6.readFileSync(file)).digest("hex");
2812
+ return crypto2.createHash("sha256").update(fs7.readFileSync(file)).digest("hex");
2456
2813
  } catch {
2457
2814
  return null;
2458
2815
  }
2459
2816
  }
2460
- async function bridgeReachable(port) {
2817
+ async function probeBridge(candidates = diagnosticPortCandidates(), opPort) {
2818
+ const order = [];
2819
+ const push = (p) => {
2820
+ if (p !== void 0 && !order.includes(p)) order.push(p);
2821
+ };
2822
+ push(opPort);
2823
+ for (const c of candidates) push(c);
2824
+ const probed = [];
2825
+ let answering = null;
2826
+ let busy = null;
2827
+ for (const port of order) {
2828
+ const r = await probePanel(port, BRIDGE_PROBE_MS);
2829
+ probed.push(r);
2830
+ if (r.status === "panel" && !answering) answering = r;
2831
+ if (r.status === "busy" && !busy) busy = r;
2832
+ if (answering) break;
2833
+ }
2834
+ const atOpPort = opPort === void 0 ? null : probed.find((p) => p.port === opPort) ?? null;
2835
+ return { answering, busy, atOpPort, probed };
2836
+ }
2837
+ var CEP_LOG_TAIL_BYTES = 256 * 1024;
2838
+ function readTail(file, bytes) {
2839
+ const fd = fs7.openSync(file, "r");
2461
2840
  try {
2462
- const res = await fetch(`http://127.0.0.1:${port}/health`, {
2463
- signal: AbortSignal.timeout(BRIDGE_PROBE_MS)
2464
- });
2465
- if (!res.ok) return { ok: false, detail: `port ${port} returned HTTP ${res.status}` };
2466
- const body = await res.json().catch(() => ({}));
2467
- return { ok: true, detail: `responding on port ${port}`, bundleHash: body.bundleHash };
2468
- } catch (e) {
2469
- if (isTimeoutError(e)) {
2470
- return {
2471
- ok: false,
2472
- timedOut: true,
2473
- detail: `port ${port} accepted the connection but did not answer within ${BRIDGE_PROBE_MS / 1e3}s \u2014 After Effects is probably busy`
2474
- };
2841
+ const size = fs7.fstatSync(fd).size;
2842
+ const start = Math.max(0, size - bytes);
2843
+ const buf = Buffer.alloc(size - start);
2844
+ fs7.readSync(fd, buf, 0, buf.length, start);
2845
+ return buf.toString("utf8");
2846
+ } finally {
2847
+ fs7.closeSync(fd);
2848
+ }
2849
+ }
2850
+ function findSignatureFailure(logs, bundleId = BUNDLE_ID) {
2851
+ const needle = `Signature verification failed for extension ${bundleId}`;
2852
+ for (const file of logs) {
2853
+ let text;
2854
+ let mtime;
2855
+ try {
2856
+ text = readTail(file, CEP_LOG_TAIL_BYTES);
2857
+ mtime = fs7.statSync(file).mtime;
2858
+ } catch {
2859
+ continue;
2860
+ }
2861
+ const lines = text.split(/\r?\n/).filter((l) => l.includes(needle));
2862
+ if (lines.length > 0) {
2863
+ return { file, line: lines[lines.length - 1].trim().slice(0, 240), mtime };
2475
2864
  }
2476
- return { ok: false, detail: `no response on port ${port} (${e.message})` };
2477
2865
  }
2866
+ return null;
2867
+ }
2868
+ function signatureWorkaroundSteps(installed) {
2869
+ return [
2870
+ "After Effects is refusing to load the panel because CEP's signature check is running even though PlayerDebugMode is on. That is an Adobe CEP 12 bug, not a broken install \u2014 nothing here needs reinstalling, and restarting After Effects again will not change it. The fix is to sign the installed panel folder yourself, with Adobe's own tool, so it passes the check.",
2871
+ "1. Download ZXPSignCmd from Adobe's CEP-Resources repository on GitHub (folder `ZXPSignCMD/4.1.3`, the build for this platform). It is a single command-line program.",
2872
+ '2. Make a self-signed certificate: `ZXPSignCmd -selfSignedCert US CA "AE MCP" "AE MCP Bridge" password cert.p12` (the country, state and names are free text; keep the password).',
2873
+ `3. Sign the installed panel folder into a zip: \`ZXPSignCmd -sign "${installed}" signed.zxp cert.p12 password -tsa http://timestamp.digicert.com\``,
2874
+ `4. Unzip signed.zxp back into that same folder, overwriting in place. That adds \`META-INF/signatures.xml\` and a \`mimetype\` file next to the panel's own files and changes nothing else. check_setup keeps reporting the panel as up to date with those extra files present.`,
2875
+ "5. Then restart After Effects \u2014 the signed panel loads on launch. Run check_setup to confirm.",
2876
+ "Caveat: setup_panel installs a fresh, unsigned copy. After any reinstall on this machine, steps 3-5 have to be done again."
2877
+ ];
2478
2878
  }
2479
- async function checkSetup() {
2879
+ async function checkSetup(opts = {}) {
2480
2880
  const checks = [];
2481
2881
  const supported = isSupportedPlatform();
2482
2882
  checks.push({
@@ -2500,11 +2900,12 @@ async function checkSetup() {
2500
2900
  fix: debugMode.on ? void 0 : `Run the setup_panel tool. After Effects only loads unsigned panels when ${debugModeLocation()} is set.`
2501
2901
  });
2502
2902
  const installed = installedPanelDir();
2503
- const isInstalled = fs6.existsSync(path7.join(installed, "CSXS", "manifest.xml"));
2903
+ const isInstalled = fs7.existsSync(path8.join(installed, "CSXS", "manifest.xml"));
2904
+ const signed = isInstalled && signedInstallPresent(installed);
2504
2905
  checks.push({
2505
2906
  name: "panelInstalled",
2506
2907
  ok: isInstalled,
2507
- detail: isInstalled ? installed : `not present at ${installed}`,
2908
+ detail: isInstalled ? `${installed}${signed ? " (self-signed: META-INF present)" : ""}` : `not present at ${installed}`,
2508
2909
  fix: isInstalled ? void 0 : "Run the setup_panel tool to install it."
2509
2910
  });
2510
2911
  let installComplete = true;
@@ -2520,7 +2921,7 @@ async function checkSetup() {
2520
2921
  });
2521
2922
  }
2522
2923
  if (isInstalled) {
2523
- const panelWs = path7.join(installed, "node_modules", "ws");
2924
+ const panelWs = path8.join(installed, "node_modules", "ws");
2524
2925
  const wsOk = isWsModuleDir(panelWs);
2525
2926
  checks.push({
2526
2927
  name: "panelDependencies",
@@ -2536,16 +2937,47 @@ async function checkSetup() {
2536
2937
  detail: running ? "running" : "not running",
2537
2938
  fix: running ? void 0 : "Launch After Effects. The panel starts automatically with it."
2538
2939
  });
2539
- const port = discoverPort();
2540
- const bridge = await bridgeReachable(port);
2940
+ const bridge = await probeBridge(opts.candidates, opts.opPort);
2941
+ const fromFile = portFilePort();
2942
+ const bridgeTimedOut = !bridge.answering && bridge.busy !== null;
2943
+ let bridgeDetail;
2944
+ if (bridge.answering) {
2945
+ bridgeDetail = `responding on port ${bridge.answering.port}`;
2946
+ if (fromFile !== null && fromFile !== bridge.answering.port) {
2947
+ bridgeDetail += ` (the port file says ${fromFile}, which is stale \u2014 the socket is the authority)`;
2948
+ }
2949
+ } else if (bridge.busy) {
2950
+ bridgeDetail = `port ${bridge.busy.port} accepted the connection but did not answer within ${BRIDGE_PROBE_MS / 1e3}s \u2014 After Effects is probably busy`;
2951
+ } else {
2952
+ const ports = bridge.probed.map((p) => p.port).join(", ");
2953
+ const reasons = bridge.probed.map((p) => p.detail).join("; ");
2954
+ bridgeDetail = `no response on port${bridge.probed.length > 1 ? "s" : ""} ${ports} (${reasons})`;
2955
+ }
2541
2956
  checks.push({
2542
2957
  name: "bridgeReachable",
2543
- ok: bridge.ok,
2544
- detail: bridge.detail,
2545
- fix: bridge.ok ? void 0 : bridge.timedOut ? BRIDGE_BUSY_FIX : "If the other checks pass, restart After Effects so the panel reloads."
2958
+ ok: bridge.answering !== null,
2959
+ detail: bridgeDetail,
2960
+ fix: bridge.answering ? void 0 : bridgeTimedOut ? BRIDGE_BUSY_FIX : "If the other checks pass, restart After Effects so the panel reloads."
2546
2961
  });
2547
- if (bridge.ok && source) {
2548
- const assessment = assessPanel(bridge.bundleHash, sha256(path7.join(installed, "jsx", "bundle.jsx")), {
2962
+ if (opts.opPort !== void 0 && (bridge.answering || bridge.busy)) {
2963
+ const opPort = opts.opPort;
2964
+ const agrees = bridge.answering !== null && bridge.answering.port === opPort;
2965
+ let detail;
2966
+ let fix;
2967
+ if (agrees) {
2968
+ detail = `tool calls go to port ${opPort} and the panel answers there`;
2969
+ } else if (bridge.answering) {
2970
+ const pinned = pinnedPort() === opPort;
2971
+ detail = pinned ? `tool calls are pinned to port ${opPort} by AE_MCP_PORT (${bridge.atOpPort?.detail ?? "not probed"}), but the panel is answering on port ${bridge.answering.port}` : `tool calls are being sent to port ${opPort} (${bridge.atOpPort?.detail ?? "not probed"}), but the panel is answering on port ${bridge.answering.port}`;
2972
+ fix = pinned ? `AE_MCP_PORT pins tool calls to port ${opPort}, and a pin is never walked past, so retrying changes nothing. Set AE_MCP_PORT to ${bridge.answering.port}, or unset it, then reconnect the MCP server (restart the client's connection to it). Do NOT restart After Effects for this: a panel is listening, and a restart would cost the user their work in progress for nothing.` : `The server's port is stale; the panel is fine. Retry the failed call \u2014 the server re-checks the port whenever a call is refused and switches to ${bridge.answering.port} by itself. If it still fails, reconnect the MCP server (restart the client's connection to it) so it starts on the right port. Do NOT restart After Effects for this: a panel is listening, and a restart would cost the user their work in progress for nothing.`;
2973
+ } else {
2974
+ detail = `tool calls are being sent to port ${opPort} (${bridge.atOpPort?.detail ?? "not probed"}); port ${bridge.busy.port} accepted a connection but is busy and could not be confirmed as the panel`;
2975
+ fix = `Wait for After Effects to finish what it is doing, then retry the call \u2014 once port ${bridge.busy.port} answers, the server switches to it on the next refused call. Do not restart After Effects: something is listening there.`;
2976
+ }
2977
+ checks.push({ name: "portAgreement", ok: agrees, detail, fix });
2978
+ }
2979
+ if (bridge.answering && source) {
2980
+ const assessment = assessPanel(bridge.answering.health?.bundleHash, sha256(path8.join(installed, "jsx", "bundle.jsx")), {
2549
2981
  installComplete
2550
2982
  });
2551
2983
  const ok = assessment.state === "current";
@@ -2556,16 +2988,44 @@ async function checkSetup() {
2556
2988
  fix: ok ? void 0 : assessment.message
2557
2989
  });
2558
2990
  }
2559
- if (bridge.ok && !isInstalled) {
2991
+ if (bridge.answering && !isInstalled) {
2560
2992
  checks.push({
2561
2993
  name: "panelIdentity",
2562
2994
  ok: false,
2563
- detail: `a panel is answering on port ${port}, but not the one at ${installed}`,
2995
+ detail: `a panel is answering on port ${bridge.answering.port}, but not the one at ${installed}`,
2564
2996
  fix: `An older install is serving the bridge. Look in ${cepExtensionsDir()} for a differently named folder, remove it, then run setup_panel and restart After Effects.`
2565
2997
  });
2566
2998
  }
2999
+ if (running && isInstalled && !bridge.answering && !bridgeTimedOut) {
3000
+ const logs = cepLogPaths();
3001
+ const finding = findSignatureFailure(logs);
3002
+ if (finding) {
3003
+ checks.push({
3004
+ name: "panelSignature",
3005
+ ok: false,
3006
+ evidence: "failure-logged",
3007
+ detail: `the CEP log ${finding.file} (last written ${finding.mtime.toISOString()}) records: "${finding.line}" \u2014 CEP is enforcing signature verification on this panel even though PlayerDebugMode is on`,
3008
+ fix: "CEP is refusing the unsigned panel's signature despite PlayerDebugMode \u2014 an Adobe CEP 12 bug, not a broken install. No reinstall and no restart changes that; the panel folder has to be self-signed with Adobe's ZXPSignCmd. nextSteps carries the exact steps."
3009
+ });
3010
+ } else if (logs.length > 0) {
3011
+ const level = await cepLogLevel(CSXS_VERSIONS[0]);
3012
+ checks.push({
3013
+ name: "panelSignature",
3014
+ ok: true,
3015
+ evidence: "log-clean",
3016
+ detail: `no signature failure recorded in ${logs[0]}` + (level === null ? " (CEP LogLevel is not set, so the log may predate this launch)" : ` (CEP LogLevel ${level})`)
3017
+ });
3018
+ } else {
3019
+ checks.push({
3020
+ name: "panelSignature",
3021
+ ok: true,
3022
+ evidence: "no-log",
3023
+ detail: `no CEP log found in ${cepLogDir()} \u2014 CEP is not logging, so a signature refusal cannot be ruled out; nextSteps says how to turn the log on`
3024
+ });
3025
+ }
3026
+ }
2567
3027
  const ready = checks.every((c) => c.ok);
2568
- return { ready, checks, nextSteps: buildNextSteps(checks, ready, bridge.timedOut === true) };
3028
+ return { ready, checks, nextSteps: buildNextSteps(checks, ready, bridgeTimedOut) };
2569
3029
  }
2570
3030
  function buildNextSteps(checks, ready, bridgeTimedOut = false) {
2571
3031
  if (ready) return ["Everything is connected. After Effects is ready to drive."];
@@ -2573,15 +3033,39 @@ function buildNextSteps(checks, ready, bridgeTimedOut = false) {
2573
3033
  const steps = [];
2574
3034
  if (by("platform")?.ok === false) return [by("platform").fix];
2575
3035
  if (by("panelAssetsPresent")?.ok === false) return [by("panelAssetsPresent").fix];
3036
+ const agreement = by("portAgreement");
3037
+ if (agreement && agreement.ok === false && by("bridgeReachable")?.ok === true && /pinned to port/.test(agreement.detail)) {
3038
+ return [
3039
+ `Do not restart After Effects \u2014 the panel is running and answering. ${agreement.detail}.`,
3040
+ "AE_MCP_PORT pins the server to that port and a pin is never walked past, so retrying will not help: set AE_MCP_PORT to the port the panel answers on, or unset it, then reconnect the MCP server \u2014 restart the client's connection to it, not After Effects.",
3041
+ "Nothing about the install is wrong; setup_panel would change nothing here."
3042
+ ];
3043
+ }
3044
+ if (agreement && agreement.ok === false && by("bridgeReachable")?.ok === true) {
3045
+ return [
3046
+ `Do not restart After Effects \u2014 the panel is running and answering. ${agreement.detail}.`,
3047
+ "Retry the call that failed. The server re-checks the port whenever a call is refused and switches to the port that answers, so the retry normally succeeds on its own.",
3048
+ "If it still fails, reconnect the MCP server \u2014 restart the client's connection to it, not After Effects \u2014 so it starts on the right port.",
3049
+ "Nothing about the install is wrong; setup_panel would change nothing here."
3050
+ ];
3051
+ }
2576
3052
  const installSound = by("panelInstalled")?.ok !== false && by("panelUpToDate")?.ok !== false && by("panelDependencies")?.ok !== false && by("cepDebugMode")?.ok !== false;
2577
3053
  if (bridgeTimedOut && installSound) {
2578
- return [
3054
+ const out = [
2579
3055
  "Wait \u2014 do not restart anything yet. The panel is answering its port but is too busy to reply, which almost always means After Effects is still running a script.",
2580
3056
  "Run check_setup again in about ten seconds, and keep checking for up to a minute. It usually clears on its own.",
2581
3057
  "While waiting, ask the user to look at After Effects: a dialog it is waiting on (unsaved project, missing fonts) blocks it the same way and may be hidden behind another window.",
2582
- "On macOS, if they have moved to a different desktop, ask them to switch back to the one After Effects is on.",
2583
- "Only if it is still not answering after a minute should you treat it as disconnected and quit and reopen After Effects."
3058
+ "On macOS, if they have moved to a different desktop, ask them to switch back to the one After Effects is on."
2584
3059
  ];
3060
+ if (agreement && agreement.ok === false) {
3061
+ out.push(`${agreement.detail}. Once it answers, the server switches to it on the next refused call \u2014 no reconnect needed.`);
3062
+ }
3063
+ out.push("Only if it is still not answering after a minute should you treat it as disconnected and quit and reopen After Effects.");
3064
+ return out;
3065
+ }
3066
+ const signature = by("panelSignature");
3067
+ if (signature && signature.ok === false) {
3068
+ return signatureWorkaroundSteps(installedPanelDir());
2585
3069
  }
2586
3070
  const brokenInstall = by("panelUpToDate")?.ok === false || by("panelDependencies")?.ok === false;
2587
3071
  const needsInstall = by("panelInstalled")?.ok === false || brokenInstall;
@@ -2608,15 +3092,30 @@ function buildNextSteps(checks, ready, bridgeTimedOut = false) {
2608
3092
  );
2609
3093
  }
2610
3094
  if (steps.length === 0 && by("bridgeReachable")?.ok === false) {
2611
- steps.push("Everything is installed but the panel is not answering. Quit and reopen After Effects.");
2612
- steps.push("If it still fails, open Window > Extensions > AE MCP Bridge in AE to see the panel's own status log.");
3095
+ steps.push(
3096
+ "Everything is installed but the panel is not answering. If After Effects has not been restarted since the panel was installed, quit and reopen After Effects once \u2014 the panel loads only at launch."
3097
+ );
3098
+ if (signature?.evidence === "log-clean") {
3099
+ steps.push(
3100
+ `If it has already been restarted, the CEP log shows no signature refusal (${signature.detail}), so that is not the cause. Ask the user to open Window > Extensions > AE MCP Bridge in After Effects and read back what the panel says; if nothing opens at all, report it with log_issue.`
3101
+ );
3102
+ } else {
3103
+ steps.push(
3104
+ "If it has already been restarted and still does not answer, the likely cause is CEP refusing the unsigned panel's signature even though PlayerDebugMode is on \u2014 an Adobe CEP 12 bug, seen on Windows (issue #91). Nothing about the install is wrong, and restarting again will not help."
3105
+ );
3106
+ steps.push(
3107
+ `To confirm it, turn CEP's own logging up so it records why the panel did not load: run \`${cepLogLevelCommand()}\` (After Effects 2026 uses CSXS.${CSXS_VERSIONS[0]}; setup_panel sets this for new installs). Then relaunch After Effects and run check_setup again \u2014 it reads the CEP log, and if the signature check is what is failing it reports panelSignature with the exact fix.`
3108
+ );
3109
+ steps.push("Also ask the user to open Window > Extensions > AE MCP Bridge in AE: if nothing appears at all, that is the same symptom.");
3110
+ }
2613
3111
  }
2614
3112
  return steps;
2615
3113
  }
2616
3114
 
2617
3115
  // src/setup/install.ts
2618
- import fs7 from "node:fs";
2619
- import path8 from "node:path";
3116
+ import fs8 from "node:fs";
3117
+ import path9 from "node:path";
3118
+ var SIGNED_INSTALL_OVERWRITTEN_NOTE = "The previous install had been self-signed (META-INF/signatures.xml was present). This fresh copy is NOT signed. On a machine where CEP refuses unsigned panels despite PlayerDebugMode \u2014 the issue #91 case, seen on Windows \u2014 After Effects will not load it until it is signed again: repeat the ZXPSignCmd steps from check_setup's panelSignature nextSteps on the newly installed folder, then restart After Effects.";
2620
3119
  async function installPanel(opts = {}) {
2621
3120
  const actions = [];
2622
3121
  const notes = [];
@@ -2629,7 +3128,7 @@ async function installPanel(opts = {}) {
2629
3128
  if (!source) {
2630
3129
  throw new Error("Could not find the CEP panel assets that ship with this server. Reinstall the package.");
2631
3130
  }
2632
- if (!fs7.existsSync(path8.join(source, "jsx", "bundle.jsx"))) {
3131
+ if (!fs8.existsSync(path9.join(source, "jsx", "bundle.jsx"))) {
2633
3132
  throw new Error(`The panel at ${source} has no jsx/bundle.jsx. In a git checkout, run \`npm run build:jsx\` first.`);
2634
3133
  }
2635
3134
  const ws = wsModuleDir();
@@ -2639,9 +3138,9 @@ async function installPanel(opts = {}) {
2639
3138
  );
2640
3139
  }
2641
3140
  const target = installedPanelDir();
2642
- const existing = fs7.lstatSync(target, { throwIfNoEntry: false });
3141
+ const existing = fs8.lstatSync(target, { throwIfNoEntry: false });
2643
3142
  if (existing?.isSymbolicLink() && !opts.force) {
2644
- const linkTarget = fs7.readlinkSync(target);
3143
+ const linkTarget = fs8.readlinkSync(target);
2645
3144
  return {
2646
3145
  ok: true,
2647
3146
  panelPath: target,
@@ -2654,16 +3153,17 @@ async function installPanel(opts = {}) {
2654
3153
  ]
2655
3154
  };
2656
3155
  }
3156
+ const wasSigned = existing ? signedInstallPresent(target) : false;
2657
3157
  if (existing) {
2658
- fs7.rmSync(target, { recursive: true, force: true });
2659
- actions.push("Removed the previously installed panel.");
3158
+ fs8.rmSync(target, { recursive: true, force: true });
3159
+ actions.push(wasSigned ? "Removed the previously installed (self-signed) panel." : "Removed the previously installed panel.");
2660
3160
  }
2661
- fs7.mkdirSync(path8.dirname(target), { recursive: true });
3161
+ fs8.mkdirSync(path9.dirname(target), { recursive: true });
2662
3162
  copyRecursive(source, target);
2663
3163
  actions.push(`Installed the panel to ${target}.`);
2664
- const dest = path8.join(target, "node_modules", "ws");
2665
- fs7.mkdirSync(path8.dirname(dest), { recursive: true });
2666
- fs7.rmSync(dest, { recursive: true, force: true });
3164
+ const dest = path9.join(target, "node_modules", "ws");
3165
+ fs8.mkdirSync(path9.dirname(dest), { recursive: true });
3166
+ fs8.rmSync(dest, { recursive: true, force: true });
2667
3167
  copyRecursive(ws, dest);
2668
3168
  if (!isWsModuleDir(dest)) {
2669
3169
  throw new Error(
@@ -2686,6 +3186,15 @@ async function installPanel(opts = {}) {
2686
3186
  }
2687
3187
  }
2688
3188
  }
3189
+ if (opts.enableDebugMode !== false) {
3190
+ const logging = await ensureCepLogging();
3191
+ if (logging.set.length > 0) {
3192
+ actions.push(
3193
+ `Set CEP's LogLevel to ${CEP_LOG_LEVEL} for CSXS ${logging.set.join(", ")} so that, if After Effects ever refuses to load the panel, its CEP log says why.`
3194
+ );
3195
+ }
3196
+ }
3197
+ if (wasSigned) notes.push(SIGNED_INSTALL_OVERWRITTEN_NOTE);
2689
3198
  if (rebootRecommended) {
2690
3199
  notes.push("PlayerDebugMode was just turned on. Quit and reopen After Effects; if the panel still does not connect, restart the Mac once \u2014 on some macOS builds the preference only applies after a reboot.");
2691
3200
  }
@@ -2695,39 +3204,146 @@ async function installPanel(opts = {}) {
2695
3204
  actions,
2696
3205
  restartAfterEffects: true,
2697
3206
  rebootRecommended,
2698
- notes
3207
+ notes,
3208
+ signedInstallOverwritten: wasSigned || void 0
2699
3209
  };
2700
3210
  }
2701
3211
 
3212
+ // src/tools/whatsNew.ts
3213
+ var RELEASE_HEADING = /^## v?(\d+\.\d+\.\d+)\s*$/;
3214
+ function parseSemver(v) {
3215
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
3216
+ if (!m) return null;
3217
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
3218
+ }
3219
+ function compareSemver(a, b) {
3220
+ const pa = parseSemver(a);
3221
+ const pb = parseSemver(b);
3222
+ if (!pa) throw new Error(`Not a version: ${JSON.stringify(a)}`);
3223
+ if (!pb) throw new Error(`Not a version: ${JSON.stringify(b)}`);
3224
+ for (let i = 0; i < 3; i++) {
3225
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
3226
+ }
3227
+ return 0;
3228
+ }
3229
+ function splitReleases(body) {
3230
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
3231
+ const preamble = [];
3232
+ const sections = [];
3233
+ let current = null;
3234
+ for (const line of lines) {
3235
+ const m = RELEASE_HEADING.exec(line);
3236
+ if (m) {
3237
+ if (current) sections.push({ version: current.version, text: current.lines.join("\n").trimEnd() });
3238
+ current = { version: m[1], lines: [line] };
3239
+ } else if (current) {
3240
+ current.lines.push(line);
3241
+ } else {
3242
+ preamble.push(line);
3243
+ }
3244
+ }
3245
+ if (current) sections.push({ version: current.version, text: current.lines.join("\n").trimEnd() });
3246
+ return { preamble: preamble.join("\n").trimEnd(), sections };
3247
+ }
3248
+ function renderWhatsNew(body, opts) {
3249
+ const { preamble, sections } = splitReleases(body);
3250
+ const newest = sections.length > 0 ? sections[0].version : null;
3251
+ const server = `This server is after-effects-mcp ${opts.serverVersion}.`;
3252
+ if (opts.since === void 0) {
3253
+ return {
3254
+ text: `${server} Pass \`since\` to see only the releases after a version.
3255
+
3256
+ ${body.trim()}`,
3257
+ versions: sections.map((s) => s.version),
3258
+ newest
3259
+ };
3260
+ }
3261
+ const since = opts.since.replace(/^v/, "");
3262
+ const newer = sections.filter((s) => compareSemver(s.version, since) > 0);
3263
+ if (newer.length === 0) {
3264
+ const described = newest ? `the newest release described here is ${newest}` : "this guide describes no releases";
3265
+ return {
3266
+ text: `${server} Nothing newer than ${since}: ${described}.`,
3267
+ versions: [],
3268
+ newest
3269
+ };
3270
+ }
3271
+ const versions = newer.map((s) => s.version);
3272
+ const header = `${server} Showing releases after ${since}: ${versions.join(", ")}.`;
3273
+ const parts = [header, preamble, ...newer.map((s) => s.text)].filter((p) => p.length > 0);
3274
+ return { text: parts.join("\n\n"), versions, newest };
3275
+ }
3276
+
2702
3277
  // src/generated/content.ts
2703
3278
  var GUIDES = [
2704
3279
  {
2705
3280
  name: "ae-setup",
2706
- description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
2707
- body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, work out which of **three** failures you have. They\nread alike and their remedies contradict each other:\n\n- **Did not answer in time** \u2014 something is listening; it is just too busy to\n reply. After Effects is single-threaded, so a long script or a modal dialog\n waiting for a click blocks it completely. Nothing is broken and nothing needs\n installing. The call **did** reach After Effects and may still be running, so\n do not re-send it.\n- **Cannot reach** \u2014 nothing is listening. That is the case the repair path\n below is for.\n- **Waited behind another op for the write queue and was dropped** \u2014 the panel\n is fine and this call never left the server, so nothing in the project was\n changed. Writes are serialized so that they land in the order the agent issued\n them and nothing drops into the middle of work the user asked for as one\n thing; something in front is taking a very long time, usually a long\n `run_batch`. Re-sending **is** safe here, once the work in\n front has finished; this is the one of the three where it is. Find out what is\n in front with `get_job` or `await_job` \u2014 reads are never queued, so `list_`\n and `get_` calls still answer.\n\nThe message itself tells you which one you have; the queue error says in as many\nwords that nothing was written. Never collapse them into "the bridge is playing\nup", because "re-send it" and "do not re-send it" are the two answers.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\nA **full** write queue is a fourth message and a different instruction again:\ntoo many calls are already waiting, so stop issuing writes and let it drain. If\nyou have that much independent work, send it as one `run_batch` \u2014 one\nExtendScript pass and one place in the queue, instead of dozens of each.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on the Adobe preference that permits unsigned panels. Both changes are user-level and reversible.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering. Read the detail: if the port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening at all, restarting AE almost always fixes it. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
3281
+ description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding, bridge on the wrong port, CEP refusing the panel's signature. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
3282
+ body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, work out which of **four** failures you have. They\nread alike and their remedies contradict each other:\n\n- **Did not answer in time** \u2014 something is listening; it is just too busy to\n reply. After Effects is single-threaded, so a long script or a modal dialog\n waiting for a click blocks it completely. Nothing is broken and nothing needs\n installing. The call **did** reach After Effects and may still be running, so\n do not re-send it.\n- **Cannot reach** \u2014 nothing is listening on the port the server tried. That\n is the case the repair path below is for \u2014 *unless* the next case applies,\n so read the message to the end: it now says which other ports it looked at\n and what it found there.\n- **Cannot reach on port X, while `check_setup` reports the panel on port Y**\n \u2014 the server\'s idea of the port went stale, not the panel. The panel is\n running and answering; the server was started from a port file that no\n longer described it. `check_setup` reports this as its own check,\n `portAgreement`, with both numbers. Retry the failed call: the server\n re-checks the port whenever a call is refused and switches to the port that\n answers by itself. If it still fails, reconnect the MCP server (restart the\n client\'s connection to it, not After Effects). **Never restart After Effects\n for this** \u2014 something is listening, and a restart costs the user their work\n in progress for nothing.\n- **Waited behind another op for the write queue and was dropped** \u2014 the panel\n is fine and this call never left the server, so nothing in the project was\n changed. Writes are serialized so that they land in the order the agent issued\n them and nothing drops into the middle of work the user asked for as one\n thing; something in front is taking a very long time, usually a long\n `run_batch`. Re-sending **is** safe here, once the work in\n front has finished; this is the one of the four where it is. Find out what is\n in front with `get_job` or `await_job` \u2014 reads are never queued, so `list_`\n and `get_` calls still answer.\n\nThe message itself tells you which one you have; the queue error says in as many\nwords that nothing was written. Never collapse them into "the bridge is playing\nup", because "re-send it", "do not re-send it" and "do not restart it" are three\ndifferent answers.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\nA **full** write queue is a fifth message and a different instruction again:\ntoo many calls are already waiting, so stop issuing writes and let it drain. If\nyou have that much independent work, send it as one `run_batch` \u2014 one\nExtendScript pass and one place in the queue, instead of dozens of each.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on two Adobe preferences \u2014 the one that permits unsigned panels, and CEP\'s own log (only where it was never set), so that if AE ever refuses to load the panel the reason is recorded. All of it is user-level and reversible. One caution: if `check_setup` showed the install as **self-signed**, `setup_panel` replaces it with an unsigned copy and the signing has to be done again \u2014 say so before calling it.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. When it passes, the detail also says whether the install is **self-signed** \u2014 see the last section. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. A self-signed install\'s extra files (`META-INF/`, `mimetype`) do not count as differences. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering **on any port** \u2014 it asks the default port and the port file\'s port both, and reports whichever answers. Read the detail: if a port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening anywhere, one restart of AE is reasonable, because the panel loads only at launch; if that has already been tried, read `panelSignature` and `nextSteps` before suggesting a second. |\n| `portAgreement` | The panel answers, but on a different port from the one tool calls go to. The server is stale, the panel is fine. Retry the call, or reconnect the MCP server. Never restart After Effects for this. |\n| `panelSignature` | CEP\'s own log says it refused to load the panel because its signature failed verification \u2014 even with `cepDebugMode` on. An Adobe CEP 12 bug, seen on Windows. No restart and no reinstall helps; `nextSteps` carries the self-signing fix. When it *passes* on a silent bridge, its `evidence` says whether the log was clean or there was no log to read. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## The port, and why it is not the port file\n\nThe panel binds port 7777. On one machine nothing else ever uses it, so if\nthe port is taken when the panel starts, the holder is a previous copy of the\npanel itself \u2014 a leftover from a relaunch, or a second panel window. The panel\ndoes not move to another port in that case: it says so in its own status\n("waiting \u2014 port 7777 is held by another AE MCP panel"), and retries until the\nold one exits, then takes over with no restart. If a user reports that status\nand it does not clear, the leftover process is still running; quitting After\nEffects fully and reopening it clears it.\n\nThe panel moves only when something that is *not* an AE MCP panel holds the\nport, and it logs which port it chose and why. People who really run two\nAfter Effects instances at once can opt back into moving past their own panel\nwith a small file the panel reads at launch,\n`~/.engineroom-ae-mcp/config.json` (`%USERPROFILE%\\.engineroom-ae-mcp\\config.json`\non Windows): `{ "port": 7777, "allowPortWalk": true }`. The server is told\nwhich port to use with `AE_MCP_PORT`, and with that set it never looks\nelsewhere.\n\nWhatever the panel bound goes in the port file beside that config. The server\nreads it once at startup as a hint and no more: the socket is the authority,\nand when a call is refused the server asks 7777 and the file\'s port which one\nactually answers.\n\n## The panel is installed, AE is open, and nothing is listening\n\nIf every check passes except `bridgeReachable`, After Effects has already been\nrestarted once since the install, and nothing appears when the user opens\n**Window > Extensions > AE MCP Bridge** \u2014 no window, no error \u2014 then the\nlikely cause is CEP refusing the panel\'s signature despite `cepDebugMode`\nbeing on. It is an Adobe CEP 12 bug, seen on Windows with After Effects 2026,\nand nothing about the install is wrong. Do not send the user round a third\nrestart.\n\n`check_setup` reads CEP\'s own log and reports `panelSignature` when it finds\nthe refusal. If there is no log to read, `nextSteps` says how to turn CEP\nlogging on (one command, which it spells out; `setup_panel` sets it on new\ninstalls), after which one relaunch of AE and one more `check_setup` gives\na definite answer.\n\nThe fix is to sign the installed panel folder with Adobe\'s own `ZXPSignCmd`\nand unzip the result back over the folder in place. `nextSteps` carries the\nexact commands. Two things to tell the user: the signing survives updates to\nthe server but **not** a reinstall \u2014 `setup_panel` puts an unsigned copy back\nand warns when it does \u2014 and `check_setup` keeps reporting a signed install as\nup to date, so the extra files it adds are nothing to worry about.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, whether it is waiting for a port another panel holds, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
2708
3283
  },
2709
3284
  {
2710
3285
  name: "after-effects",
2711
- description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, building and animating layers, keyframes and easing, expressions, effects, text and shapes, and the gotchas that silently produce wrong output. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes.",
2712
- body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every change immediately, and every tool call is a real undo step in their project. Work like a motion designer at the keyboard, not like a script that fires blind.\n\nTwo things sit *behind* this guide rather than in it, so they are not resident in\nevery session. Both are reachable either way \u2014 `ae_guide({topic: \u2026})` from any\nclient, or the file beside this one if you loaded this as a skill:\n\n- **`extendscript-gotchas`** (`references/extendscript-gotchas.md`) \u2014 read it\n before writing any raw ExtendScript for `run_jsx`. Property lookups that return\n null, what `copyToComp` really does, comp time versus layer time, the reserved\n words that stop a script before its first line.\n- **`whats-new`** (`references/whats-new.md`) \u2014 what changed recently. Read it\n when a call behaves differently from what you expected, or the user says a tool\n used to work another way.\n\n## Read the house style first\n\n`get_house_style` returns the style guide for the project that is currently open\n\u2014 palette, type, motion defaults, layout rules \u2014 read from `house-style.md`\nsitting next to the `.aep` file. Call it once at the start of any build task and\nfollow what it says. It costs one cheap call and it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nWhat comes back is a **digest**, not the document: the palette as named hexes,\nthe type, the motion defaults, the layout rules, and a note naming anything it\ncould not summarise. That is a few hundred tokens and it is what you build from.\nPass `detail: "full"` when you need the guide\'s own wording \u2014 and always before\n`set_house_style`, which replaces the whole file, so you have to send the merged\ndocument rather than a patch. A guide written as unstructured prose comes back\n`structured: false` with its opening text, which is an honest "I could not read\nthis as a spec", not an empty answer.\n\nIf it reports `found: false`, build with sensible defaults and offer once, at the\nend, to capture a style guide from what you just made. Don\'t nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this:\n\n| Question | Tool |\n|---|---|\n| What\'s in this project? | `get_project_summary` |\n| What comps exist? | `list_comps` |\n| What\'s in this comp? | `get_comp_tree` |\n| Everything about one layer | `get_layer_full` \u2B50 |\n| Where is a layer, by name/type/effect? | `find_layers` |\n| What did my last change actually do? | `snapshot_comp` \u2192 `diff_comp` \u2B50 |\n\n**Making a variant of something** is `duplicate_comp`, which returns the new id\nso you never go looking for it by name. Its default is a **shallow** copy, the\nsame as AE\'s own Duplicate: the copy\'s precomp layers point at the *same* nested\ncomps, so editing one edits both. That is right for "another version of this\nshot" and wrong for "a variant of this rig" \u2014 for that pass `deep: true`, which\nduplicates the nested comps too and re-points the copy at them.\n\n`get_layer_full` is the one to reach for. It returns transforms **with their keyframes and expressions**, effects with every parameter, masks, markers, and `sourceRect` (the layer\'s visible bounds) in a single call. Prefer one `get_layer_full` over four narrow queries \u2014 it is faster and it shows you context you did not know to ask for.\n\n### Ask for what you need\n\nA tool result stays in your context for the rest of the session, so a read you cannot bound is paid for on every later call. All of these reads take an `include` list:\n\n- `list_comps` / `list_layers` with `include: []` return the id-to-name map alone, which is what orientation actually needs.\n- `get_layer_full` takes `include` (`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`, `source`), plus `maxKeyframes` to cap the keyframes per property and `shapeDepth` to limit the Contents walk on a heavy shape layer.\n\nOmit them all and you get everything, as before. Whatever they leave out is named and counted in the response \u2014 a bounded read never looks like a complete one.\n\n**Reading a shape layer, use `shapeDetail: "compact"`.** It returns one indented line per group \u2014 the group\'s name, its matchName, then its own properties as `name=value`, with `[3 keys]` or `[expr]` on the animated ones and `(at defaults)` for a group Transform nobody has touched. Every name the write tools address a node by is still on the line, and it costs a fraction of the full JSON form. Reach for `"full"` when you need exact values, keyframe detail or indices.\n\nOne thing is left out of both forms: **Material Options**, the 48-property 3D extrusion block AE hangs off every vector group. It only means anything for an extruded shape under the Cinema 4D renderer, and on the 2D shape layers that are nearly all of them it was most of the weight of the read \u2014 a single 68px circle cost 4,400 tokens, of which the geometry was about 40. `materialsOmitted` counts what was skipped; `shapeMaterials: true` brings it back.\n\n## Identify things by ID, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based position that **shifts whenever layers are added, deleted, or reordered**. Store `(compId, layerId)` and pass those. An index captured before a `create_*` call may point at a different layer by the time you use it.\n\nThe same trap bites inside `run_jsx`: a `comp.layer(1)` wrapper is index-bound, not a handle. Once a `copyToComp` or a `duplicate()` has shifted the destination\'s indices, a reference you took earlier silently resolves to a *different* layer \u2014 which is how a script ends up parenting a layer to itself. Re-resolve by id or name after anything that inserts a layer.\n\n### Reordering the layer stack\n\n`reorder_layer` takes **exactly one** destination. Prefer the id forms:\n`beforeLayerId` puts the layer directly in front of (above) that layer,\n`afterLayerId` directly behind it. `toIndex` is absolute \u2014 1 is the front,\n`numLayers` the back \u2014 and it means the index the layer **ends up at**, not the\nslot it displaces; those two readings differ by one when moving down the stack.\n\nReach for an index only when you genuinely mean "put it on top" or "send it to\nthe back". This is the op that shifts every index below it, so an index you read\nbefore the move may already be stale by the time you use it \u2014 which is the same\nreason nothing else here is addressed by index. The result carries `movedFrom`\nalongside the landed `index`, read back off the layer; equal values mean it was\nalready there.\n\n## Read, then write, then verify\n\n1. Read the current state (`get_layer_full`).\n2. Make the change.\n3. Verify by reading back the properties \u2014 not by screenshotting.\n\nProperty values are the ground truth. A screenshot tells you something *looks* wrong; `get_layer_full` tells you *why*.\n\n### Verify with a diff, not a second full read\n\nRe-reading a comp to see what changed makes you compare two large answers by eye,\nand you pay for both for the rest of the session. Fingerprint it instead:\n\n- `snapshot_comp({compId})` **before** the write returns a `snapshotId` and\n almost nothing else. `diff_comp({since})` afterwards returns only what moved \u2014\n layers added, removed, renamed, retimed, re-parented, keyframe counts that\n changed, expressions and effects gained or lost \u2014 and a count of the layers\n that did not. Tens of tokens where the two reads were thousands.\n- `run_jsx` and `run_batch` take `diff: true`, which does the same thing *inside*\n the call, so there is no window between the write and the fingerprint. On a\n failure the diff rides on the error, which is the cheapest way to find where a\n half-applied script stopped \u2014 nothing rolls back, so that is the question you\n will actually have.\n\nKnow what a fingerprint does **not** record: property values, expression text,\neffect parameters, masks and shape contents. So `changeCount: 0` means none of\nthe recorded fields moved \u2014 not that the comp is unchanged. Retype a text layer\nor change a colour and the diff is empty and correct. For "is this value right",\nread the property.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not screenshot every frame, do not scrub through time, do not screenshot after every edit.\n\n**To judge motion, ask for a contact sheet \u2014 one call, not three.**\n`screenshot_frame({compId, times: [0, 1, 2]})` takes two to six times and returns\na *single* tiled image with the time burned into each tile, held to roughly the\npixel budget of one frame. It is cheaper than separate calls, it is one image\nresident in your context instead of three, and it gives After Effects one render\nrequest instead of three back-to-back ones \u2014 which is the pattern most likely to\ncome back stale. `time` and `times` are mutually exclusive. There is no `times`\non `screenshot_layer`.\n\n- **An image is the most expensive result this server returns**, and like every result it stays in your context for the rest of the session. One sheet is a budget for a whole build.\n- **The `downsample` is picked from the comp size** unless you pass one \u2014 2 at 1080p, 3 at 4K, aiming at a long edge around 1280px, and per tile on a sheet. Pass `downsample: 1` only when you genuinely need full resolution: a full 4K frame is large enough to blow out your context in one call. It does now mean full resolution \u2014 the render sets the comp\'s resolution explicitly and restores it afterwards, so a viewer the designer left on Quarter no longer silently changes the size of the frame you asked for.\n- The result reports the dimensions actually returned and the factor actually applied \u2014 trust those numbers rather than assuming.\n- **Space single frames out.** Rapid back-to-back requests are far more likely to come back stale than requests a few seconds apart. A sheet does this for you.\n\n### The four results that are not a picture\n\nThey have different causes and opposite remedies, so read which one you got\nbefore you retry anything.\n\n- **`Stale frame`** \u2014 After Effects returned pixels it had already rendered for a *different* request, which the error names. Wait a few seconds and retry at a higher `downsample`; `6` has worked where `3`\u2013`4` stayed stale. If two frames of a genuinely static comp really are identical, a different `downsample` renders a different number of pixels and proves it.\n- **`Corrupt frame`** \u2014 the render stopped writing and the file is not a whole PNG, so nothing was sent. **This is not a timeout.** It tracks how heavy the comp is: retry at `downsample` 6\u20138, or screenshot the shot precomps one at a time instead of the assembly.\n- **`Render timed out`** \u2014 After Effects was still working when the panel gave up. The render is probably still going, so wait a few seconds before doing anything else; a retry issued now queues behind it.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent, so no image was sent. That is a fact about the composition: usually the wrong time, a layer outside its in/out points, disabled, or at zero opacity.\n\nOn a contact sheet a single bad tile is drawn as a marked block and named in `warning` \u2014 the rest of the sheet is still good, so read it rather than re-requesting the whole thing.\n\n**Never disable layers to make a screenshot render.** A frame that will not render is a limit of the panel\'s render path, not project content that needs fixing \u2014 and it is very easy to leave someone\'s comp switched off afterwards.\n\nTo check motion exactly, read the keyframe values. A picture tells you it looks wrong; `get_keyframes` tells you why.\n\n## Bulk work goes through run_batch\n\nBuilding 40 layers with 40 separate calls is slow. `run_batch` runs many ops in\none ExtendScript pass \u2014 far faster, and far fewer undo steps.\n\n**How many undo steps depends on the size, and you have to read it rather than\nassume it.** After Effects discards an undo group opened in one script call and\nclosed in another, which is not written down anywhere in Adobe\'s documentation\nand was measured on 26.3.\n\n- **Up to 500 ops: exactly one undo step.** The whole batch runs inside one\n script call, so the group round it survives. One Cmd-Z takes the lot.\n- **Over 500 ops: one undo step per chunk of 25** \u2014 about 24 for 600 ops. It\n returns a `jobId`, streams progress, and finishes with `await_job(jobId)`.\n- **`singleUndo: true` forces one step at any size up to 2000 ops**, by running\n the whole thing in one blocking call. The cost is real and the user sees it:\n After Effects\' interface is frozen for the entire batch and no progress is\n reported. Over 2000 it is refused rather than freezing AE for minutes.\n\n**Read `undoSteps` before you tell anyone how to undo the work.** Every result\ncarries the *measured* count and a `note` in plain words, and the `{jobId}`\nenvelope carries `undoStepsEstimate` \u2014 which is the only number you have at the\nmoment you would otherwise be promising the user a single Cmd-Z. Never say "one\nundo" for a chunked batch.\n\n**Nothing rolls back, on either setting.** `transactional: true` (the default)\n*stops* at the first failing op; the ops before it stay applied, and the result\nsays `rolledBack: false` and names where it stopped. `transactional: false` runs\nthe rest and collects the errors. Either way, read the state back \u2014 `diff: true`\nappends a structural diff of the comps the batch touched, and on a failure that\ndiff rides on the error, which is the cheapest way to find the stop point.\n\n**You do not have to issue writes one at a time.** The server holds a single\nwrite lock for the whole session, so independent writing calls sent together run\nin the order you issued them, one after another, and a long `run_batch` holds the\nlock until its last chunk lands \u2014 so another call cannot land in the middle of\nwork the user asked for as one thing. A call that had to wait says so with\n`queuedBehind` and `waitedMs`; a call that did not is unchanged. Reads are never\nqueued, so `list_layers`, `get_layer_full` and `await_job` still answer while a\nbatch runs.\n\nPrefer `run_batch` anyway when the work is one user action: one ExtendScript pass\nrather than many, and far fewer undo steps than the same ops sent separately.\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n\n**Pass one `{influence, speed}` pair per side and nothing else.** `set_temporal_ease` and `add_keyframe` size the ease array themselves and report what the property wanted as `easeDimensions`. That number is worth glancing at, because it is not derivable from the value: a 2D layer\'s Scale takes 2, a shape\'s Ellipse Size takes 3, Opacity and sliders take 1, and a spatial property \u2014 Position, Anchor Point \u2014 takes exactly 1 whether the layer is 2D or 3D, since the ease runs along the motion path rather than per axis. Getting it wrong by hand throws a bare `parameter 2`, which is why you no longer do it by hand. If you are easing a property from `run_jsx` instead, use the `ease()` helper \u2014 it is the same sizing code, not a second copy of it.\n\n## Rigging\n\nNulls, parents and retimed layers. Parenting carries less than people expect, and\neach of the four below has cost a review round more than once.\n\n**Opacity does not propagate through parenting.** Scale, rotation and position\nride the parent; opacity never does. Every text or child layer under a shape that\npops or stamps in needs its own matching opacity keys, or it sits there on screen\nbefore its parent has revealed anything.\n\n**Parent world layers to a camera null *before* you key the null, at a time where\nit is still at identity** \u2014 anchor and position at the comp centre, scale 100.\nParenting compensation is a no-op there, so the children keep plain world\ncoordinates and the keyframes they already had. To look at a world target `T` at\nzoom `s`: key the null\'s scale to `s` and its position to `C + (C \u2212 T)\xB7s/100`,\nwith `C` the comp centre. Children of a precomp layer get parented while that\nparent is at rest, and AE rewrites their position and divides their scale for you.\n\n**Anything flown out of frame is still there when the camera moves.** A layer\nparked at y = \u2212900 comes straight back into shot on a whip-up. Cut its opacity\nonce it is clear rather than trusting the frame edge to hide it.\n\n**Expressions run on comp time, not layer time.** Retiming a layer with\n`startTime` moves its keyframes and leaves its expressions exactly where they\nwere, so a freeze written against `time` replays from zero. Offset `time` inside\nthe expression, or key the value instead.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform","Position"]` or `["Effects","Gaussian Blur","Blurriness"]`. Expressions are ExtendScript-flavoured JavaScript evaluated by AE per frame.\n\nExpressions are usually a better answer than dense keyframes for anything procedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay editable by the user afterwards, where a wall of baked keyframes does not.\n\nUse `get_expression` to read one back and `toggle_expression` to disable without deleting.\n\n## Effects\n\nEffects are added by **matchName**, not display name: `add_effect({matchName: "ADBE Gaussian Blur 2"})`. If you do not know a matchName, call `list_available_effects({filter: "blur"})` \u2014 do not guess. `list_effects` shows what is already on a layer, with every parameter.\n\nSet parameters with `set_effect_param` by parameter name (e.g. `"Blurriness"`).\n\n**Never enumerate `app.effects` yourself in `run_jsx`.** There are around 250 of them and reading the table is slow enough to block the bridge past its timeout, which looks exactly like a crash and costs a minute of everyone\'s time. `list_available_effects` does the same enumeration once and caches it for the session, so `filter` searches are free after the first call. A wrong matchName also fails instantly and clearly, so trying `ADBE Slider Control` is cheaper than searching for it.\n\n## Text\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph justification** and leaves the anchor point at `[0,0]`, so `position` is the start of the first baseline. Pass `"center"` or `"right"` for those, `"none"` for AE\'s raw behaviour. Because the alignment is justification rather than a measured offset, it stays correct when the text changes later \u2014 retyped, driven by an expression, or edited through Essential Graphics in Premiere. Never "fix" alignment by writing an anchor point computed from `sourceRectAtTime()`: it is right once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise inherits whatever the user\'s Character panel was last left on.\n\n`set_text` controls font, size, colour, tracking, leading and justification. To auto-fit a background to text, read `sourceRect` from `get_layer_full` and size the shape from its width and height plus padding.\n\n## Shapes\n\n`create_shape_layer` puts the new layer\'s origin at `[0,0]` with its anchor at `[0,0]`, so **the layer\'s coordinate space is the comp\'s** and every vertex, rect position and path you add afterwards is in comp pixels. After Effects\' own spawn point is the comp centre, which silently offsets a drawing authored in comp coordinates by half a frame; pass `position: "center"` if you want that back, or any `[x,y]` to place the origin yourself. The result echoes the position and anchor it ended up with.\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`, `ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`, `group`. Properties are set with friendly names in the same call (`size`, `position`, `roundness`, `color`, `width`, `lineCap`, \u2026).\n\nThis tool is **all-or-nothing**: if a key cannot be applied, the whole node is removed and you get an error naming the bad key. A success result therefore means everything landed. Don\'t add defensive re-reads for it, but do read the error carefully \u2014 it usually means the property is named differently on that node type, and `get_layer_full` will show you the real name.\n\nFor a custom path, use `{type: "path", vertices: [[x,y], \u2026], closed: true}`. The key is `vertices`, not `points`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1 renders in *front*, and each `add_shape_content` call appends behind the previous one. So build **front-to-back**: details, text plates and traffic-light dots first, the big background rectangle last. Getting it backwards is silent \u2014 no error, just a solid slab where your artwork should be. `zOrder: "front"` will place a node at index 1 for you, but it needs an internal `moveTo`, which has been seen to disturb *nested* renders of the comp in AE 26.3; prefer ordering your calls. If existing shape *content* is already in the wrong order, rebuild it rather than reordering, and verify with a screenshot of a comp that **nests** it, not just the comp that owns it. None of this applies to the layer stack: moving whole layers is `reorder_layer`, which is a different mechanism with none of these caveats.\n\n**Node references go stale.** Adding a sibling to a group invalidates a reference you already hold to another node in it \u2014 add a Stroke and an earlier Fill reference starts throwing `Object is invalid`. Add every node first, then set values and expressions by addressing nodes by name.\n\n## Sound\n\n`place_audio_cues` scores a scene in one call: a list of cues \u2014 each a file (or an\nalready-imported `footageId`), a comp `time` and a `levelDb` \u2014 becomes one\naudio layer each, imported once however many cues name the same file, named,\ntrimmed, labelled, in a single undo step. Reach for it the moment you are placing\nmore than two or three sounds.\n\nIt is all-or-nothing: every cue is checked (the file exists, the item has an\naudio track, the time is inside the comp) before a single layer is made, and if a\nlater one still fails, everything the call created is removed and the error names\nthe cue by index. `dryRun: true` checks a list against the project without\nimporting, creating, or even adding an undo step. `levelDb` is decibels, AE\'s own\nunit \u2014 `0` is the file as recorded, negative is quieter \u2014 and `inPoint`/`outPoint`\ntrim in **comp** time, not file time.\n\n## The escape hatch\n\n`run_jsx` executes arbitrary ExtendScript. Reach for it when a needed operation has no tool \u2014 driving the render queue, batch-renaming, a bulk edit no single op expresses. Check the tool list first: duplicating a comp, easing a keyframe, reordering a layer and placing a shape layer at a sane origin all have tools now, and each of them wraps a trap you would otherwise hit. `reorder_layer` is worth singling out \u2014 it was broken for three releases, so an agent may have learned to route around it; the way round it in ExtendScript is `layer.moveTo()`, which does not exist and never worked.\n\n**Read the gotchas before you write one** \u2014 `ae_guide({topic: "extendscript-gotchas"})`, or `references/extendscript-gotchas.md` beside this file. It is the list of things that fail while naming something else: a property lookup that returns null and surfaces twenty lines later, a `copyToComp` that does not insert where you think, a reserved word that stops the whole script before its first line. Every item on it has already cost somebody an aborted run.\n\nThe reference also carries the two things that make a script cheap to write: `scriptPath` and `libraries`, which keep a long script and its shared helpers out of the conversation entirely, and the helpers already in scope \u2014 including the whole `OPS` table, so every tool on this server is callable from inside a script.\n\nExtendScript is **single-threaded**, so a long synchronous loop freezes the user\'s AE UI. Keep the script short.\n\n`return X` sends the whole value back \u2014 arrays and nested objects included. Values that cannot be represented (functions, live AE objects, cycles) come back as a marker string in place, never dropped \u2014 a live object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to pass to `get_layer_full`, not a copy of the layer. So an empty result genuinely means the script returned nothing; never read one as "nothing happened".\n\n**A bare expression is not a return.** `"ping";` as the last line yields nothing, and so does any script that just does its work. That case comes back as `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the script ran to completion*. Do not re-run it. Nothing rolls back, so a second run of a script that duplicated a layer, reordered content or wrote keyframes applies all of it twice; read the state back instead, and add an explicit `return` if you want a value.\n\n**On a failure, read the error before you touch anything.** It names the line of *your* script and prints that line\'s text; when the number cannot be mapped honestly it says so rather than guessing at one. Everything above that line already ran and nothing rolls back \u2014 so find out what landed, never re-run the script to see whether it fails again. `diff: true` is the fast way to find out: it fingerprints the comp around the script and appends only what changed, and on a throw that diff rides on the error message.\n\n### Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate` from `run_jsx`. That call raises modal dialogs, and a modal dialog freezes this whole connection until someone clicks it in After Effects \u2014 but suppressing them, which is what you have to do, costs you the only channel AE has for saying why an export failed. It answers with a bare boolean and nothing else. So the tool checks every precondition it can *before* exporting, which is the half of its job you cannot do from a script.\n\n`export_mogrt` handles all of it: it saves the project first (which is what removes AE\'s "the project needs to be saved" prompt, and it has to happen per export because exporting dirties the project again), it suppresses the font warning, and it runs outside the undo group so there is no "undo group mismatch" afterwards. Measured on 26.3: suppressed, an export of a comp using a non-Adobe font returns in about three seconds; unsuppressed, the same export sat past sixty and wrote nothing until the dialog was clicked.\n\nFour things worth knowing before you call it. The first is the one that actually stops exports:\n\n- **The comp needs at least one property in its Essential Graphics panel.** After Effects will not build a template from an empty one, and it refuses silently \u2014 no file, no dialog, not a word. The tool checks the controller count first and refuses before touching anything. The fix is in AE and the user has to do it: Window > Essential Graphics, pick the comp, drag a layer property in.\n- **The project must have been saved once, by hand.** There is no folder to save into otherwise, and the tool refuses rather than raising a dialog the user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE\'s own default is the literal `Untitled` \u2014 leave it to AE and every template in the project overwrites the same file.\n- **`fonts` in the result lists what the template will require.** Tell the user about any non-Adobe ones: Premiere flags the template as needing fonts it cannot supply, and that is worth hearing from you rather than discovering later.\n\n**If an export fails anyway, read the message rather than guessing.** It lists what was checked and ruled out, and it only names a modal dialog when dialogs were left *unsuppressed*. Under the default suppression a dialog is impossible by construction, so the cause is genuinely unknown \u2014 say so, and tell the user the one place AE\'s own reason exists: exporting the same comp by hand from the Essential Graphics panel, where AE shows its error in the interface. Do not send them looking for a dialog that cannot be there.\n\n**The thumbnail.** AE writes the comp\'s *first frame* into the template, so anything that fades up from nothing gets a black one. Pass `posterTime` with a moment that actually shows the design and it is rendered and swapped in. If only the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched` in the result.\n\nAlso note that `comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in **reverse order of addition**: index 1 is the one you added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a crash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from `run_jsx` raises the save prompt the same way; use `screenshot_frame`, which does not.\n\n### Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in a comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails silently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports with **fabricated dimensions and renders as nothing**, no error at any stage. Verified on 26.3 \u2014 that viewBox yields a 15906x5654 item that will not even rasterize. The tool compares the aspect ratio the file asks for against the one AE produced, and on a mismatch it deletes the item and throws, rather than handing you an asset that looks healthy in the project panel and renders empty.\n\nIf you hit that, the workarounds are:\n\n- **Simple flat SVGs** \u2014 rebuild the path as a shape layer with the real vertices, scaled down to a sane coordinate space (divide by `333.334` for a 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule` to `2` when the SVG says `fill-rule="evenodd"`. Done this way the result is pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of throwing. It is for when you know the dimensions are wrong and want it anyway \u2014 not a way past the error.\n\n## When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. Two tools\nexist so that each one is only paid for once.\n\n**`list_known_issues`** \u2014 what earlier sessions hit and how they got past it.\nRead it when a tool fails in a way you do not immediately understand, before you\nstart guessing. The answer is often already there. It comes back as a one-line\nindex, so open the entry that looks like your failure with\n`list_known_issues({id})` \u2014 the cause and the workaround are in the entry, not in\nthe index. `tool` and `query` narrow it further.\n\nThere are two journals and every entry says which it came from. `project` is\nthis project\'s own notes; `user` travels with the person across every project.\nIds are only unique within a journal, so open an entry with the qualified form\nthe listing\'s `next` pointer shows you \u2014 `list_known_issues({id: "user:\u2026"})`.\n\n**`log_issue`** \u2014 write down what you worked out, the moment you work it out.\n\nLog something when all three are true: it cost real effort, it was the tool\'s\nfault rather than yours, and the next session would hit it too. A schema that\naccepts an argument AE then rejects, an error message that names the wrong\nthing, a property whose real name is nothing like its display name. Not your own\ntypos. Not "I forgot the layer was 3D".\n\nWrite the entry for someone who has not seen the failure: the exact error text,\nthe call that produced it, and a workaround concrete enough to apply directly.\nReuse the existing title when you are extending an entry \u2014 that keeps one good\nrecord instead of five thin ones.\n\n**Pick the scope by what the entry is about, not by where you are.** Leave it at\nthe default `project` for this project\'s footage, comps or files. Pass\n`scope: "user"` when it is about how these tools or After Effects behave \u2014 that\nis nearly everything worth logging, and it is the difference between the next\nproject starting out knowing it and re-learning it.\n\n### Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it handles\nthe rest. If they say no, drop it; the note stays and can be offered again\nanother time.\n\nNever claim you have reported something you have not.\n\n## When something is not connected\n\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay its `nextSteps` to the user in plain language. Do not try to diagnose CEP by hand.\n\n**A timeout is not proof the bridge is dead.** The error that says the panel did not answer in time is a different thing from the one that says the panel cannot be reached. Because ExtendScript is single-threaded, a busy After Effects cannot answer anything \u2014 so a long script, or a modal dialog nobody has clicked, is indistinguishable from a crash at this layer. It normally recovers on its own within a minute.\n\nSo when a call times out: do not re-send it (you would queue the same work twice), do not restart After Effects, and do not run `setup_panel`. Poll `check_setup` for about a minute first. Two causes worth asking about directly:\n\n- **A dialog is waiting.** Ask the user to check After Effects for a prompt hiding behind another window.\n- **They changed desktop.** On macOS, calls have been reported to stall while the user is on a different Space and to complete as soon as they return. If they have wandered off, ask them to switch back to the desktop After Effects is on before you diagnose anything else.\n\nIf a specific operation of yours legitimately needs longer than the limit, the user can raise it by setting `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment.\n\n**A dropped write is the opposite case, and it is safe to re-send.** If a call\ncomes back saying it *waited behind* another op for the write queue and was\ndropped without running, the bridge is fine and nothing reached After Effects.\nSomething in front is slow \u2014 usually a long `run_batch`, occasionally a modal\ndialog. Find out what with `get_job` or `await_job`; reads are never queued, so\n`list_*` and `get_*` still answer and will tell you the current state. Then\nre-send once the work in front has finished. That is exactly what you must *not*\ndo after a bridge timeout, so read which of the two you got.'
3286
+ description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, bounding every read, verifying a write with a diff, screenshots as a one-off diagnostic with their cost in tokens, write ordering, and the three bridge failures whose remedies contradict each other. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes; it points at one reference per subject for everything else.",
3287
+ body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every\nchange immediately, and every tool call is a real undo step in their project.\nWork like a motion designer at the keyboard, not like a script that fires blind.\n\nThis file holds only what silently produces wrong output on *any* task. Each\nsubject sits behind it in a reference, loaded when a task reaches that subject\nand never otherwise. From any client, `ae_guide({topic: \u2026})`; loaded as a\nskill, the file beside this one:\n\n| When the task reaches\u2026 | Topic | File |\n|---|---|---|\n| keyframes, easing, rigging, expressions | `animation` | `references/animation.md` |\n| a shape layer and its Contents | `shapes` | `references/shapes.md` |\n| a text layer | `text` | `references/text.md` |\n| shots placed into a master comp, markers, retiming a shot | `assembly` | `references/assembly.md` |\n| any raw ExtendScript for `run_jsx` \u2014 read it **before** writing the script | `extendscript-gotchas` | `references/extendscript-gotchas.md` |\n| sound effects and beds | `sound` | `references/sound.md` |\n| a `.mogrt` export, importing footage, or the solids a deleted comp leaves behind | `mogrt-and-footage` | `references/mogrt-and-footage.md` |\n| a tool that fought back, and what to do with what you learned | `issue-journal` | `references/issue-journal.md` |\n| a call that behaves differently from what you remember | `whats-new` | `references/whats-new.md` |\n\nTwo topics are siblings rather than references: `style-guide`, for capturing the\nuser\'s look, and `ae-setup`, for a connection that is not working.\n\n## Read the house style first\n\n`get_house_style` returns the project\'s style guide \u2014 palette, type, motion\ndefaults, layout rules \u2014 read from `house-style.md` beside the `.aep`. Call it\nonce at the start of any build task and follow it; it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nWhat comes back is a **digest** of a few hundred tokens, which is what you build\nfrom. Pass `detail: "full"` only when you need the guide\'s own wording \u2014 and\nalways before `set_house_style`, which replaces the whole file, so you send the\nmerged document rather than a patch. `structured: false` means the guide could\nnot be read as a spec and its opening text is returned instead; `found: false`\nmeans there is none \u2014 build with sensible defaults and offer once, at the end,\nto capture one from what you made. Do not nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this, and the\nbounded form is the one to reach for:\n\n| Question | Call |\n|---|---|\n| What is in this project? | `get_project_summary` |\n| What comps exist? | `list_comps({include: []})` \u2014 ids and names |\n| What is in this comp? | `list_layers({compId, include: []})` \u2014 id, index, name, type; `get_comp_tree` for the nesting |\n| Where is a layer, by name, type or effect? | `find_layers` \u2014 `{matches, count, compsSearched, included}`; a match is id, index, name, sourceType, compId and compName, and `include` (`flags`, `timing`, `parent` \u2014 the `list_layers` names) widens it. Unlike `list_layers`, omitting `include` here does **not** return everything |\n| One layer, in depth | `get_layer_full({compId, layerId, include: [\u2026]})` \u2B50 |\n| What did my last change actually do? | `snapshot_comp` \u2192 `diff_comp` \u2B50 |\n\n**Bound every read.** A tool result is re-sent to you on every later request\nfor the rest of the session, so an unbounded read is paid for once when you\nmake it and again on every request until the session ends. `include: []` on the\nlist calls; on `get_layer_full`, `include` names the sections you want\n(`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`,\n`source`), `maxKeyframes` caps the keyframes per property, `shapeDepth` limits\nthe Contents walk, and `shapeDetail: "compact"` reads a shape layer as one line\nper group. Omit them all and you get everything. Whatever a bound leaves out is\nnamed and counted in the response, so a bounded read never passes for a\ncomplete one.\n\n**One bounded `get_layer_full` beats several narrow calls.** It returns\ntransforms with their keyframes and expressions, effects with every parameter,\nmasks, markers and `sourceRect` (the layer\'s visible bounds) in one answer, so\nwhen you need three of those, name the three in `include` rather than making\nthree calls. The bound is the `include` list, not the number of calls.\n\n**Making a variant of something** is `duplicate_comp`, which returns the new id\nso you never look for the copy by name. Its default is a **shallow** copy, the\nsame as AE\'s own Duplicate: the copy\'s precomp layers point at the *same* nested\ncomps, so editing one edits both. That is right for "another version of this\nshot" and wrong for "a variant of this rig" \u2014 for that pass `deep: true`, which\nduplicates the nested comps and re-points the copy at them.\n\n**Effects are added by matchName**, not display name:\n`add_effect({matchName: "ADBE Gaussian Blur 2"})`, then `set_effect_param` by\nparameter name. If you do not know a matchName, `list_available_effects({filter:\n"blur"})` \u2014 always with a `filter`, since the unfiltered list is several hundred\nentries and takes seconds every time. `list_effects` shows what is already on a\nlayer with every parameter.\n\n## Identify things by id, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based\nposition that **shifts whenever layers are added, deleted or reordered**. Store\n`(compId, layerId)` and pass those; an index captured before a `create_*` call\nmay point at a different layer by the time you use it.\n\n`reorder_layer` is the op that shifts every index below it, so it takes\n**exactly one** destination and the id forms are the ones to use:\n`beforeLayerId` puts the layer directly in front of (above) that layer,\n`afterLayerId` directly behind it. `toIndex` is absolute \u2014 1 is the front,\n`numLayers` the back \u2014 and means the index the layer **ends up at**, not the\nslot it displaces. Reach for it only when you genuinely mean "on top" or "at the\nback". The result carries `movedFrom` beside the landed `index`.\n\n## Read, then write, then verify\n\n1. Read the current state (a bounded `get_layer_full`).\n2. Make the change.\n3. Verify by reading back \u2014 property values are the ground truth. A screenshot\n tells you something *looks* wrong; the property tells you *why*.\n\n**Verify with a diff, not a second full read.** Re-reading a comp to see what\nchanged makes you compare two large answers by eye, and you pay for both for the\nrest of the session. `snapshot_comp({compId})` before the write returns a\n`snapshotId` and almost nothing else; `diff_comp({since})` afterwards returns\nonly what moved \u2014 layers added, removed, renamed, retimed, re-parented, keyframe\ncounts, expressions and effects gained or lost \u2014 and a count of the layers that\ndid not. `run_jsx` and `run_batch` take `diff: true`, which does the same\n*inside* the call, so there is no window between the write and the fingerprint.\n\nA fingerprint records no property values, expression text, effect parameters,\nmasks or shape contents, so `changeCount: 0` means none of the recorded fields\nmoved \u2014 not that the comp is unchanged. For "is this value right", read the\nproperty.\n\n**A failed write is a partial write.** Nothing rolls back, on any tool: a\n`run_jsx` that throws on its fourth line has done the first three, and a\n`run_batch` that stops at op 30 has done 29. So find out what landed \u2014 on a\nfailure the `diff: true` result rides on the error, which is the cheapest way \u2014\nand never re-send the same call to see whether it fails again: the half that\nworked is applied a second time.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not\nscreenshot every frame, do not scrub through time, do not screenshot after every\nedit.\n\n**An image is the most expensive result this server returns, and it stays in\nyour context for the rest of the session.** Measured at 4K on 26.3: a frame at\n`downsample: 3` \u2014 the factor picked for a 4K comp when you pass none, a\n1280\xD7720 image \u2014 costs about 1,230 tokens; `downsample: 2` about 2,765; a full\n4K frame at `downsample: 1` can blow out your context in one call. Budget a\nbuild in a handful of sheets, not in frames.\n\n**To judge motion, ask for a contact sheet \u2014 one call, not three.**\n`screenshot_frame({compId, times: [0, 1, 2]})` takes two to six times and\nreturns a *single* tiled image with the time burned into each tile, held to\nroughly the pixel budget of one frame. `time` and `times` are mutually\nexclusive; there is no `times` on `screenshot_layer`.\n\nThe `downsample` is picked from the comp size unless you pass one \u2014 2 at 1080p,\n3 at 4K, aiming at a long edge near 1280px, per tile on a sheet. `downsample: 1`\nis genuinely full resolution: the render sets the comp\'s resolution explicitly\nand restores it afterwards, whatever the viewer\'s Resolution dropdown says. The\nresult reports the dimensions and the factor actually applied \u2014 trust those.\nSpace single frames out; rapid back-to-back requests are the pattern most likely\nto come back stale, and a sheet does the spacing for you.\n\nFour results are not a picture, and their remedies point in different\ndirections, so read which one you got before you retry anything:\n\n- **`Stale frame`** \u2014 After Effects re-served pixels it rendered for a\n *different* request, which the error names. Wait a few seconds and retry at a\n higher `downsample`; if two frames of a genuinely static comp really are\n identical, a different factor renders a different number of pixels and proves\n it.\n- **`Corrupt frame`** \u2014 the render stopped writing and the file is not a whole\n PNG, so nothing was sent. **Not a timeout.** It tracks how heavy the comp is:\n retry at `downsample` 6\u20138, or screenshot the shot precomps one at a time\n instead of the assembly.\n- **`Render timed out`** \u2014 After Effects was still working when the panel gave\n up, and probably still is. Wait a few seconds before doing anything else; a\n retry issued now queues behind it.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent. That is a\n fact about the composition: the wrong time, a layer outside its in/out points,\n disabled, or at zero opacity.\n\nOn a sheet a single bad tile is drawn as a marked block and named in `warning`;\nthe rest of the sheet is still good, so read it rather than re-requesting.\n\n**Never disable layers to make a screenshot render.** A frame that will not\nrender is a limit of the render path, not project content that needs fixing \u2014\nand it is very easy to leave someone\'s comp switched off afterwards.\n\n## Bulk work, and the order writes land in\n\n`run_batch` runs many ops in one ExtendScript pass \u2014 far faster than the same\nops as separate calls, and far fewer undo steps. **Up to 500 ops it is one undo\nstep.** Over 500 it returns a `jobId` at once, before the first chunk runs, and\nlands as **one undo step per chunk of 25** \u2014 about 24 for 600 ops \u2014 because\nAfter Effects discards an undo group that spans two script calls.\n`await_job(jobId)` finishes it, and is the call that carries progress: send it\nwith a progress token and `notifications/progress` arrive while it waits. None\ncan ride on `run_batch` itself, whose response is already back; `get_job` polls\nthe same state without a token. `singleUndo: true` forces one step at any size\nup to 2000 by running the whole batch in one blocking call, with AE\'s interface\nfrozen for the duration. Every result carries the *measured* `undoSteps`: read\nit before you tell anyone how many Cmd-Z the work takes. `transactional: true`\n(the default) stops at the first failing op; `false` runs the rest and collects\nthe errors.\nNeither rolls anything back \u2014 `diff: true` shows what landed.\n\n**You do not have to issue writes one at a time.** The server runs one write at\na time for the whole session, in the order you issued them, and a long\n`run_batch` holds that lock until its last chunk lands \u2014 so nothing drops into\nthe middle of work the user asked for as one thing. A call that had to wait says\nso with `queuedBehind` and `waitedMs`. Reads are never queued: `list_*`,\n`get_*`, the screenshots and `await_job` answer while a batch runs.\n\n## Three failures that read alike, with remedies that contradict\n\n- **Did not answer in time.** The call reached After Effects, which is\n single-threaded, so a long script or a modal dialog nobody has clicked blocks\n every reply. It may still be running: do **not** re-send it, do not restart\n After Effects, do not run `setup_panel`. Poll `check_setup` for about a\n minute, and ask the user whether a dialog is hiding behind another window. A\n call that legitimately needs longer gets it from `AE_MCP_OP_TIMEOUT_MS` in the\n server\'s environment.\n- **Cannot reach After Effects.** The connection was refused, so nothing\n reached After Effects and nothing changed. The server looks again on its own\n first \u2014 7777, then the port file \u2014 and follows a panel that has moved, so a\n refusal that reaches you is one that search did not resolve. Call\n `check_setup` and relay its `nextSteps` to the user in plain language; the\n repair path is the `ae-setup` topic. If it reports the panel answering on a\n port other than the one the call named, retry the call or reconnect the MCP\n server and never restart After Effects for it: something *is* listening, and\n a restart costs the user their work in progress. Do not diagnose CEP by hand.\n- **Waited behind another op for the write queue and was dropped.** Nothing\n reached After Effects and nothing changed. Something in front is slow \u2014 usually\n a long `run_batch`; find it with `get_job` or `await_job` \u2014 then re-send once\n it has finished. That is the one of the three where re-sending is right.'
3288
+ },
3289
+ {
3290
+ name: "animation",
3291
+ description: "Keyframes, easing, rigging and expressions through the AE MCP tools \u2014 what each keyframe tool controls, the one ease pair per side that the tools size for you, a held pose as a hold key and a waypoint that must not stop, the parenting facts that cost review rounds (at rest, before the pop, in the parent's space, a mirrored parent), building expression text from numbers, a shake as an expression over keys, and why an expression's `time` does not follow a retimed layer. Load when a task animates, parents or drives anything with an expression.",
3292
+ body: '# Keyframes, rigging and expressions\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n- `get_keyframes` reads them all back, with the ease on each key \u2014 the exact\n answer to "does it move right", where a screenshot is an impression.\n\n**Pass one `{influence, speed}` pair per side and nothing else.**\n`set_temporal_ease` and `add_keyframe` size the ease array for the property\nthemselves and report the count that worked as `easeDimensions`. That number is\nnot derivable from the value\'s dimension \u2014 After Effects wants a different count\nper property, and the measured table is in the `extendscript-gotchas` topic for\nthe day you script an ease by hand. Through the tools you never need it; from a\nscript, use the `ease()` helper, which is the same sizing code and not a second\ncopy of it.\n\n**A held pose is a hold keyframe, not two equal keys.** Between two\nequal-valued bezier keys the value still moves \u2014 the tangents are shaped by the\nneighbouring keys, so a pose drifts and a motion path bows through the hold.\nGive the key that starts the hold `set_interpolation({keyIndex, out: "hold"})`,\nor `interpolation` on the `add_keyframe` that makes it, and the value stays put\nuntil the next key.\n\n**A waypoint with speed zero is a stop.** Easing every key in a run brings the\nspeed to zero at each one, and the move visibly halts there. One move is one\nease \u2014 `set_temporal_ease` on the first and last key \u2014 with the keys between\nthem left linear (`set_interpolation({in: "linear", out: "linear"})`), so\nspeed passes through them instead of stopping.\n\nExpressions are usually a better answer than dense keyframes for anything\nprocedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay\neditable by the user afterwards, where a wall of baked keyframes does not.\n\n## Rigging\n\nNulls, parents and retimed layers. Parenting carries less than people expect,\nand each rule below has cost a review round more than once.\n\n**Opacity does not propagate through parenting.** Scale, rotation and position\nride the parent; opacity never does. Every text or child layer under a shape\nthat pops or stamps in needs its own matching opacity keys, or it sits there on\nscreen before its parent has revealed anything.\n\n**Parent world layers to a camera null *before* you key the null, at a time\nwhere it is still at identity** \u2014 anchor and position at the comp centre, scale\n100. Parenting compensation is a no-op there, so the children keep plain world\ncoordinates and the keyframes they already had. To look at a world target `T`\nat zoom `s`: key the null\'s scale to `s` and its position to `C + (C \u2212 T)\xB7s/100`,\nwith `C` the comp centre. Children of a precomp layer get parented while that\nparent is at rest, and AE rewrites their position and divides their scale for\nyou. `parent_layer` with `preserveTransform` does the arithmetic.\n\n**The same rule for any parent: parent while it is at rest, and before it is\nkeyed.** Keeping a child where it is means writing the inverse of the parent\'s\ntransform *at that moment* into the child, so a child parented under a tilt,\nmid-pop or at a scale other than 100 carries that inverse for good and reads\nwrong afterwards \u2014 and a parent whose first scale key is `0` has no inverse at\nall; a child parented there is divided by zero. Build, parent, then pop. Key a\nchild before parenting, while the parent is at identity, and its keys stay\nplain world values; a child keyed *after* parenting is keyed in the parent\'s\nspace \u2014 `world \u2212 parentPosition + parentAnchor`, and that difference divided by\nthe parent\'s scale when the parent is a scaled precomp. Either way, read the\nchild\'s position, scale and rotation back before trusting them; the result\'s\n`correction` says what was rewritten.\n\n**A parent mirrored with a negative scale mirrors its children, text included.**\nA rig flipped with scale `[\u2212100, 100]` shows every label backwards. Flip a text\nchild back with its own negative x scale about its own anchor \u2014 centre-justified,\nso the flip is about its centre \u2014 and when the parent flips mid-scene, do it by\nan expression on the child\'s scale that reads the sign of\n`parent.transform.scale[0]`, so the child follows every flip.\n\n**Anything flown out of frame is still there when the camera moves.** A layer\nparked at y = \u2212900 comes straight back into shot on a whip-up. Cut its opacity\nonce it is clear rather than trusting the frame edge to hide it.\n\n**Expressions run on comp time, not layer time.** Retiming a layer with\n`startTime` moves its keyframes and leaves its expressions exactly where they\nwere, so a freeze written against `time` \u2014 `Math.min(time, 0.75)` \u2014 replays\nfrom zero in the shifted layer. Offset `time` inside the expression\n(`time \u2212 startTime`, or rewrite `\\btime\\b` to `(time + offset)` in every\nexpression on a layer you retime), or key the value instead. A shot built in\nits own comp does not have this problem, which is the reason the `assembly`\ntopic builds shots in local time.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform", "Position"]` or\n`["Effects", "Gaussian Blur", "Blurriness"]`. Expressions are\nExtendScript-flavoured JavaScript evaluated by AE per frame.\n\n**`set_expression` throws when After Effects cannot evaluate what it wrote.**\nAssigning an expression succeeds whatever the text says; AE\'s only report is a\nwarning banner on the property in the interface, which you cannot see. So the\ntool evaluates the property after the write and, if AE reports an error on it,\nfails with AE\'s own message and the property path \u2014 a success result means the\nexpression compiles and runs. The expression is still on the property after\nsuch a failure: fix it and call `set_expression` again, or `clear_expression`\nto remove it. Do not add a second one on top. `toggle_expression({enabled:\ntrue})` verifies the same way, because enabling can surface an error that has\nsat on the property since it was disabled.\n\n`get_expression` reads one back as `expression`, `enabled` and\n`expressionError` \u2014 AE\'s message, or an empty string when it runs clean. A\nnon-empty `expressionError` means the property is not being driven by that\nexpression, whatever `enabled` says. `toggle_expression` disables one without\ndeleting it, and `clear_expression` removes it.\n\n**Build expression text from numbers explicitly.** A negative offset\nconcatenated after `time-` produces `time--2`, and an array concatenated bare\nloses its brackets (`960,540`). Write an offset as `"time-(" + t + ")"` and a\nvector as `"[" + v[0] + ", " + v[1] + "]"`. `set_expression` reports what does\nnot evaluate, with AE\'s own message, so a slip here is a failed call rather\nthan a property that quietly stops moving.\n\n**A shake on a keyed move is an expression over the keys, never more keys.**\nExtra keys fight the ease and cannot be moved with it; `value + \u2026` leaves the\nmove as keyed and adds the hit on top \u2014 a decaying sine per impact,\n`amplitude \xB7 sin(rate \xB7 d) \xB7 e^(\u2212decay \xB7 d)` with `d = time \u2212 hitTime`, summed\nover the hit times \u2014 and the null that carries the move keeps its keys\neditable.'
3293
+ },
3294
+ {
3295
+ name: "assembly",
3296
+ description: "Assembling shots into a master comp \u2014 each shot built in its own comp in local time and placed at its offset, comp markers at the beats, retiming, reversing or trimming a shot without touching its contents, the precomp that renders nothing before its startTime or past its source, what a nesting shares (controls, a resize, collapse transformations, motion blur), replacing a nested comp without losing the layers that nest it, and how an assembly, a small detail, a doubtful tile and a cut are verified. Load when a task has more than one scene, a narration to cut to, or a shot to move in time.",
3297
+ body: "# Assembling shots into a master\n\nEvery narrated job has this shape: several shots, each a few seconds, cut to\nbeats in a voice track, in one deliverable. Build it as **one comp per shot,\nplaced into a master**, and the three questions that otherwise cost a session \u2014\nwhere does this shot start, how do I move it, why is the frame black \u2014 stop\narising.\n\n## Build each shot in local time\n\nMake each shot its own comp (`create_comp`, sized and timed like the master)\nand animate it **from zero** \u2014 its first keyframe at `0`, its expressions\nwritten against the comp's own `time`. A shot comp has its own clock: when it\nis placed into the master, every keyframe and every expression inside it keeps\nworking wherever it lands, because nothing inside it ever knew about the\nmaster's timeline. That is the whole reason to build this way; a shot animated\nin the master's absolute time cannot be moved without re-keying it, and its\nexpressions do not follow \u2014 see the `animation` topic on comp time versus layer\ntime.\n\n## Place it at its offset\n\n`create_precomp_layer({compId: master, sourceCompId: shot})` places the shot as\na layer, then `set_layer({\u2026, startTime: offset})` puts its local zero at the\nbeat. `inPoint` and `outPoint` trim it in master time; `stretch` changes its\nspeed. All four leave the shot comp untouched, which is what \"retime a shot\"\nshould mean: move the layer, never the contents. To make a shot longer or\nshorter, change its content in the shot comp and then re-trim the layer.\n\n**A precomp layer cannot run past its source.** `outPoint` clamps to the shot\ncomp's duration without a word, and a layer trimmed past it \u2014 in a rig as much\nas in the master \u2014 simply ends there. Extend the shot comp's `duration` with\n`set_comp` first, then the layer's `outPoint`, and read the out point back\nfrom the result. To play a shot backwards, time-remap the layer from the\nshot's end to its start \u2014 `run_jsx`: `timeRemapEnabled = true`, then swap the\nvalues of the two default keys with `setValueAtKey` \u2014 and nothing inside the\nshot is touched.\n\n**A precomp layer renders nothing before its `startTime`.** So you cannot hold\na rigged shot at its pre-animation state by pushing `startTime` later than the\nmoment it appears \u2014 you get an empty frame until the layer starts. Freeze the\nstart by duplicating the shot comp and stripping the keys (`duplicate_comp`,\nwhich is a shallow copy by default \u2014 pass `deep: true` when the shot nests\ncomps of its own), or start the layer at the moment it should first be visible\nand let the shot's own opening hold. Freeze the *end* past the source duration\nwith time remap and `Math.min(time + off, dur \u2212 0.1)` on the layer, not by\nextending the shot comp.\n\n**Nothing on the master's timeline hides a shot that has run past its cut.**\nTrim `outPoint` at the cut, or the next shot renders on top of a tail that is\nstill animating.\n\n## What a nesting shares, and what it clips\n\n**A control inside a shot comp is shared by every layer that nests it.** Key a\nslider in the shot and every instance of the shot changes with it. A shot that\nmust differ per placement is its own comp \u2014 `duplicate_comp`, with `deep:\ntrue` when the shot nests comps of its own \u2014 keyed on its own control.\n\n**Changing a shot comp's size moves everything that nests it by half the\nchange.** A precomp layer's anchor sits at its source's centre, and after\n`set_comp` changes the width or height the anchor and the position of each\nnesting layer no longer agree, so the nested world jumps by half the\ndifference. Read every nesting layer's anchor point and position back and\nre-place it.\n\n**A precomp is clipped at its own bounds unless collapse transformations is\non.** A blur or a glow at a shot's edge is cut off at the frame, and a camera\nbelow 100 % shows the cut edge; a shot flown through at scale goes soft at 4\xD7.\nThe layer's collapse switch fixes both \u2014 vector contents render at the final\nresolution and past the bounds \u2014 and every instance of the shot needs it, not\njust one. It costs two things: an adjustment layer inside a collapsed shot acts\non the frame *behind* the shot rather than on the shot alone, and the shot\nlayer's own blending mode is ignored. Where either matters, leave collapse off\nand oversize the shot comp instead. No tool sets the switch; from `run_jsx`,\n`layerById(compId, layerId).collapseTransformation = true`.\n\n**Motion blur is a per-comp switch and a per-layer switch, and the layer\nswitch inside the shot is the one a build forgets.** The master's comp switch,\nthe precomp layer's switch and each moving layer's switch inside the shot all\nhave to be on before anything in the shot blurs; switch the shot comp's own on\ntoo, so the shot previews the same way opened alone. From `run_jsx`:\n`comp.motionBlur = true` on a comp, `layer.motionBlur = true` on a layer.\n\n**`delete_comp` on a shot takes every layer that nests it with it**, in the\nmaster and anywhere else, silently. To replace a shot with a rebuilt one, build\nthe new comp under a working name, point each nesting layer at it \u2014 `run_jsx`:\n`layer.replaceSource(newComp, false)`, then `startTime`, `inPoint` and\n`outPoint` written again, because the swap keeps the old timing \u2014 and delete\nthe old comp only once nothing nests it (`return compById(oldId).usedIn.length`\nreads `0`); then rename.\n\n## Markers at the beats\n\n`add_marker({compId, time, comment})` with no `layerId` puts a **comp marker**\non the master; with a `layerId` it marks the layer. Put one at every beat you\ncut to, with the beat's words in `comment`, before you place a single shot \u2014 the\noffsets then have a name, the user sees the structure in the timeline, and a\nlater session reads the plan off the comp instead of re-deriving it. `duration`\nturns a marker into a span, which suits a shot's whole run.\n\n## Checking an assembly\n\n**Verify the master by reading it back; screenshot the shots.**\n`get_comp_tree({compId: master})` shows the nesting, `list_layers({compId,\ninclude: [\"timing\"]})` shows where each shot starts and ends, and\n`snapshot_comp` \u2192 `diff_comp` confirms a placement moved only the layer you\nmeant. A master is the heaviest thing you can render and the first to come back\n`Corrupt frame`; a shot comp screenshots cheaply and shows the same picture. A\ncamera move across the master is a null parented while at identity \u2014 the\n`animation` topic has the arithmetic.\n\nFour rules for the screenshots you do take:\n\n- **A shot comp with no background renders its empty areas transparent, and\n what a transparent area looks like is the viewer, not the set** \u2014 white,\n often, or a sheet's dark gutter. Judge sky, ground and anything that reads\n against the background on the assembled comp over its real background, or\n put a temporary solid behind the shot.\n- **Detail under about 40 px is judged at `downsample: 1`, never from a\n downsampled frame** \u2014 a stroke, a label, a small icon blur or vanish at the\n default factor. `screenshot_layer` isolates the layer but is still a whole\n frame; for a detail in a heavy comp, a small temporary comp of a few hundred\n pixels that nests the shot scaled up around the detail is what makes full\n resolution affordable.\n- **A contact-sheet tile that contradicts a property read-back is re-rendered\n as a single frame before anything is changed.** A tile can be stale \u2014 the\n sheet names it when it can tell \u2014 and a read-back cannot, so the\n disagreement is a question about the picture, not about the property.\n- **A cut is verified with a Difference render.** In a temporary comp, the\n outgoing shot at its last frame under the incoming shot at its first\n (`create_precomp_layer` twice, `set_layer` with `startTime` so those two\n frames fall on the same comp time), the top layer at\n `set_layer({blendingMode: \"DIFFERENCE\"})`, both over one solid so both sides\n are opaque, collapse off on both \u2014 a collapsed layer ignores its blending\n mode. `screenshot_frame` at that time: anything not black is where the two\n frames disagree."
2713
3298
  },
2714
3299
  {
2715
3300
  name: "extendscript-gotchas",
2716
- description: "The ExtendScript facts that cost a run_jsx script an aborted run \u2014 property names that return null, shape node types that hide their own properties, what copyToComp actually does, comp time versus layer time, and the reserved words that stop a script before its first line. Read this before writing raw ExtendScript, not after it fails.",
2717
- body: '# ExtendScript gotchas\n\nEverything here was paid for once already. One video\'s build \u2014 fourteen scenes,\neach assembled by a fresh agent, roughly a thousand layers \u2014 lost at least one\naborted script to every item below, and three to some of them. Measured on After\nEffects 2026 (26.3) through this server at 0.3.1.\n\n**Adobe\'s scripting documentation describes an ExtendScript that After Effects\n2026 does not implement.** This is not a general caution; it is the single\nlargest source of shipped bugs in this project, and every instance was found by\nmeasuring rather than by reading. In one release: `$.evalFile` is documented as\nevaluating at global scope and in fact evaluates into the calling function\'s\nscope, so a helper library defined nothing anything could call. `Error.start`\nand `Error.end` are documented as character offsets into the source and are\nin fact `0` on every error, however far in the throw was \u2014 so code that trusted\nthem reported line 1 for everything. An undo group opened in one script call and\nclosed in another is silently discarded, so a 600-op batch that claimed to be one\nundo step was six hundred. `CompItem.posterTime` does not exist at all. And\n`exportAsMotionGraphicsTemplate` invalidates `app.project` itself, not just the\ncomp you passed it.\n\nNone of these raised. Each one returned exactly what success returns. So when\nthis page and Adobe\'s reference disagree, this page was measured; and if you are\nabout to rely on a documented behaviour that nothing here mentions, write the\nthree-line probe that proves it before you build on it.\n\nRead it **before** you write the script. Most of these fail in a way that names\nthe wrong thing: a null returned here surfaces as `null is not an object` twenty\nlines later, and a reserved word means **nothing in the script runs at all**\nwhile the error points at one line in the middle.\n\nThree rules that make all of it cheaper:\n\n- **Nothing rolls back.** A script that fails halfway leaves everything before\n the failure applied. A failure names the line of your script and prints its\n text, so start there \u2014 then read the state back, or pass `diff: true` and let\n the call tell you what landed. Never re-run a mutating script to see whether\n it fails again.\n- **Prefer the tool.** Most of what follows is a trap that only exists because\n you dropped to raw scripting. `add_shape_content`, `set_temporal_ease`,\n `create_shape_layer`, `place_audio_cues`, `duplicate_comp` and `parent_layer`\n resolve names, sizes and orders for you and fail loudly when they cannot. Each\n item below names the tool that already handles it, where there is one.\n- **Do not paste what is already in scope.** See "What you already have" at the\n end before you write a helper of your own.\n\n## Property names that return null\n\n`layer.property(matchName)` returns **null** for a name that does not exist on\nthat layer. It does not throw, so the failure lands later, somewhere else.\n\n- **2D rotation is `ADBE Rotate Z`.** `layer.property("ADBE Rotation")` is null.\n `layer.transform.rotation` is the safe form.\n- **Audio levels are `layer.audioLevels`.**\n `layer.property("ADBE Audio Levels")` is null on an audio layer, which is the\n trap a hand-written sound-placement loop hits on its first cue. *The tool\n already does:* `place_audio_cues` places a whole cue list in one undo step,\n all-or-nothing, with a `dryRun`.\n- **Time remap is an attribute, not a property lookup.**\n `layer.property("ADBE Time Remap")` is null on audio layers and on precomp\n layers, *even after* setting `timeRemapEnabled = true`. Use `layer.timeRemap`.\n Audio levels are `layer.audioLevels` the same way.\n- **`instanceof` is unreliable on host objects.** `x instanceof Layer` cannot be\n trusted to identify a layer kind. Probe instead: a shape layer is one where\n `property("ADBE Root Vectors Group")` is non-null.\n\nWhen a lookup might miss, check for null on the line that does the lookup. That\nturns a misleading error twenty lines away into an accurate one here.\n\n## Shapes\n\n- **`comp.layers.addShape()` spawns the layer at the comp centre** (960,540 at\n 1080p, 1920,1080 at 4K) with its anchor at (0,0). If you then build contents in\n comp coordinates the whole drawing lands offset by half a frame. Zero the\n position immediately, before you add anything to `Contents`. *The tool already\n does:* `create_shape_layer` defaults to `[0,0]` (`position: "center"` restores\n AE\'s spawn point), and the `shape(comp, {name, position})` helper does it\n inside a script.\n- **Polystar type is `1 = Star`, `2 = Polygon`** on `ADBE Vector Star Type`.\n Type 2 *hides* Inner Radius, so setting inner radius after choosing polygon\n throws `property is hidden`. A gear is type 1.\n- **Stroke dashes do not take.** `addProperty("ADBE Vector Stroke Dash 1")`\n followed by `setValue` throws the same hidden-property error on this build. A\n dashed or hazard-tape band that does work: a small square plus a Repeater\n (`ADBE Vector Repeater Transform` \u2192 `ADBE Vector Repeater Position` set to\n `[2 * side, 0]`) in a group in front, over a plain rect in a group behind.\n- **Render order is the reverse of the layer stack.** Index 1 in `Contents`\n renders in front and `addProperty` appends to the end, so the first node you\n add is the one on top. Build front-to-back.\n- **`moveTo` is a shape-node method, not a layer method.** It re-ranks a\n `PropertyBase` inside an indexed group, so `layer.moveTo(n)` throws\n `parent is not an INDEXED_GROUP` \u2014 an error naming a concept the caller never\n mentioned. Layers move with `moveBefore` / `moveAfter` / `moveToBeginning` /\n `moveToEnd`, all four of which take a **layer**, never an index, and the two\n directions need different primitives: moving up, `moveBefore` lands on the\n target; moving down, the target shifts up as the layer leaves, so `moveAfter`\n is the one that lands on it. Getting that backwards is off by one with no\n error. *The tool already does:* `reorder_layer`, with `beforeLayerId` /\n `afterLayerId` / `toIndex`.\n- **A node reference goes stale when a sibling is added.** Hold a Fill, add a\n Stroke to the same group, and the Fill reference starts throwing\n `Object is invalid`. Add every node first, then re-fetch by name before\n setting values.\n\n## Layers and comps\n\n- **`copyToComp` does not put the copy at index 1.** The first copy lands on\n top; each later copy lands *below the previous copy*, so `dest.layer(1)` keeps\n handing back the same layer while you think you are collecting new ones.\n Identify the copy by diffing the set of layer ids before and after the call.\n- **`copyToComp` needs the undo group closed.** AE refuses to copy a layer that\n has a parent or a linked expression while an undo group is open \u2014 which is\n exactly the rig worth copying. Wrap that one call in\n `withoutUndoGroup(function () { \u2026 })`, or pass `undoGroup: false` for the whole\n script and accept whatever undo steps AE records on its own.\n- **A copied layer carries parent-relative values.** If it was parented to a null\n that was not at identity, it renders offset in the destination. Check the first\n frame against the source; fix it with an intermediate null carrying the inverse\n offset, re-parenting while the new parent is at identity.\n- **A comp layer renders nothing before its `startTime`.** You cannot hold a\n rigged precomp at its pre-animation state by pushing `startTime` past the shot\n \u2014 you get an empty frame. Freeze the start by duplicating the comp and\n stripping the keys; freeze the end past the source duration with time remap and\n `Math.min(time + off, dur - 0.1)`.\n- **Set the parent first, the transform second.** Raw `layer.parent = x` inside a\n script does not reliably preserve where the layer sits two levels deep, so\n after scripted parenting audit scale and rotation as well as position.\n `parent_layer` does this correctly and takes `preserveTransform`.\n- **`app.executeCommand(id)` silently no-ops** through this bridge. Menu commands\n depend on host focus and the active selection, and this bridge has neither. It\n returns without complaint, which is how `run_batch`\'s "transactional rollback"\n spent three releases firing `findMenuCommandId("Undo")` and undoing nothing \u2014\n while reporting that it had. Use the API equivalents \u2014\n `CompItem.duplicate()`, `layer.duplicate()`.\n *The tool already does:* `duplicate_comp` returns the new comp id, and\n `deep: true` duplicates the nested comps and re-points the copy at them, which\n `CompItem.duplicate()` alone does not.\n\n## Undo groups\n\n- **An undo group does not survive a script boundary.** `beginUndoGroup` in one\n `evalScript` call and `endUndoGroup` in another produces no group at all \u2014\n After Effects discards it, `endUndoGroup()` returns exactly as it does on\n success, and the only place the truth is visible is AE\'s Edit menu. Anything\n spanning calls has to open and close its own group per call and count them.\n *The tool already does:* `run_batch` reports the measured `undoSteps`, and\n `singleUndo: true` buys one step by staying inside a single call.\n- **`copyToComp` needs the group closed**, which is the opposite problem \u2014 see\n Layers and comps above.\n\n## Keyframes and expressions\n\n- **`setTemporalEaseAtKey` sizes its ease array per property, not per value\n dimension.** A 2D layer\'s Scale wants 3, a shape Ellipse Size wants 2, a slider\n or Opacity wants 1, and a *spatial* property \u2014 Position, Anchor Point \u2014 wants\n exactly 1 whether the layer is 2D or 3D, because the ease runs along the motion\n path rather than per axis. The wrong count throws about `parameter 2` or\n `Value array does not have 1 elements`. If you are scripting this by hand, try\n 1 then 2 then 3 in a try/catch \u2014 or, better, do not: the `ease(prop, keyIndex,\n easeIn, easeOut)` helper is in scope and *is* the sizing code\n `set_temporal_ease` uses, not a second copy of it, so a script and a tool call\n can never disagree about what a property wanted. A bare number means\n influence; omitting `easeOut` uses the same ease both sides; the return value\n is the number of entries that worked. *The tool already does:*\n `set_temporal_ease` and `add_keyframe` take one `{influence, speed}` pair per\n side and report `easeDimensions`.\n- **The key lookup is `nearestKeyIndex(t)`**, not `nearestKeyAtTime`.\n- **Expression `time` is comp time, not layer time.** Shifting a layer\'s\n `startTime` moves its keyframes and leaves its expressions where they were, so\n a `Math.min(time, 0.75)` freeze replays from zero in the shifted copy. Rewrite\n `\\btime\\b` to `(time + offset)` in the expressions of any layer you retime.\n\n## The language itself\n\nExtendScript reserves words JavaScript does not: **`short`, `int`, `char`,\n`byte`, `long`, `float`, `double`, `boolean`**. `var short = \u2026` fails with\n`Illegal use of reserved word` and **nothing in the script runs** \u2014 every\nside effect you expected is simply absent, which reads exactly like a bridge\nfailure. `s`, `n`, `count`, `flag` cost nothing.\n\nThe rest of the dialect is ES3: no `let`/`const`, no arrow functions, no\ntemplate literals, no `Object.keys`, no destructuring, no trailing commas.\n\nIt is also **single-threaded**, so a long synchronous loop freezes the user\'s AE\nwindow and looks to this server exactly like a crash. Keep a script to work that\nfinishes in seconds; anything longer belongs in `run_batch`.\n\n## When a script fails halfway\n\n**The error names the line of your script and prints its text.** Believe it \u2014\nand when it says the number could not be mapped, believe that too: it is refusing\nto guess rather than pointing you at a plausible wrong line.\n\nEverything above that line already ran. To find out exactly what landed, pass\n`diff: true` and the call appends what changed to the error itself; otherwise\nread it back with `diff_comp`, `get_comp_tree` or `find_layers`. **Never re-run\nthe script to see whether it fails again** \u2014 nothing rolled back, so the lines\nabove the failure apply a second time.\n\nAnd remember what a *successful* script with no `return` looks like:\n`{ok: true, returned: null, undoGroup, note}`. That is completion, not failure.\nRe-running it applies every mutation a second time.\n\n## What you already have\n\nBefore you write a helper, check it is not in scope. Every one of these wraps a\ntrap on this page, and a version you write yourself re-derives the bug.\n\n- **`OPS`** \u2014 the whole tool table, callable from inside a script:\n `OPS.set_transform({compId, layerId, position: [0, 0]})`. Anything a tool\n already does well, do it this way rather than reaching into the DOM.\n- **`compById(id)` / `layerById(compOrId, layerId)`** \u2014 the pair every tool\n returns, resolved. No index arithmetic.\n- **`walkProperty(layer, ["Transform", "Position"])`** \u2014 a property path,\n resolved the same way the tools resolve it.\n- **`addKeys(prop, [[t, v], \u2026])`** \u2014 returns the key index of each, in order, so\n the next call can ease them without searching.\n- **`ease(prop, keyIndex, easeIn, easeOut)`** \u2014 sizes the KeyframeEase array.\n- **`shape(comp, {name, position})`** \u2014 a shape layer at `[0,0]`.\n- **`withoutUndoGroup(fn)`** \u2014 closes the undo group around one statement, which\n is what `copyToComp` needs.\n\nAnd two arguments rather than more code: **`scriptPath`** runs an absolute `.jsx`\npath so a long script never enters the conversation at all, and **`libraries`**\ntakes absolute `.jsx` paths and inlines them ahead of the script, in the same\nscope, so their functions are callable from it. The server reads both, so\nneither file\'s text enters the conversation. A library is re-evaluated on every\ncall \u2014 keep libraries to declarations rather than to work \u2014 and a failure inside\none is reported against that file, by name and line. Keep shared helpers in a\nlibrary instead of pasting them into every script.'
3301
+ description: "The run_jsx reference \u2014 when to script instead of calling a tool, how much one call can do before the timeout, scriptPath and libraries as the normal way to build, the helpers already in scope, and the ExtendScript facts that abort a script while naming something else (property lookups that return null, a `.value` read at the playhead rather than at a time, effects addressed by a name that is not unique, a track matte that switches its matte's video off, ease arrays sized per property, a TextDocument that stores the wrong justification, output-module settings that are read-only, the reserved words that stop a script before its first line). Read it before writing raw ExtendScript, not after it fails.",
3302
+ body: '# Scripting After Effects with run_jsx\n\n`run_jsx` executes arbitrary ExtendScript inside After Effects. Reach for it\nwhen a needed operation has no tool \u2014 the render queue, a bulk edit no single op\nexpresses, a rig that is easier to describe as a loop than as forty calls. Check\nthe tool list first: duplicating a comp, easing a keyframe, reordering a layer,\nplacing a shape layer at a sane origin and scoring a scene all have tools, and\neach wraps a trap on this page. The whole `OPS` table is in scope, so a script\ncan call any tool as a function and keep to the DOM for the part no tool covers.\n\n## How much one call can do\n\n- **Timeouts.** A call gets 120 s by default; `run_jsx`, `run_batch`, the two\n screenshot ops, `export_mogrt`, `import_footage` and `place_audio_cues` get\n 300 s. `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment raises every one of\n them. ExtendScript is single-threaded, so After Effects\' interface is frozen\n for as long as the script runs, and a script that outlives its limit looks to\n the server exactly like a dead bridge \u2014 the "did not answer in time" failure \u2014\n while it carries on running to the end.\n- **In practice, about 60 layers\' worth of creation and keying per call** is\n where a scene build stays well inside the limit (reported from a\n fourteen-scene, thousand-layer build). Split a big build by scene, not by op;\n the same op repeated hundreds of times is `run_batch`, which chunks itself.\n- **Source size** \u2014 the script plus every library it names \u2014 is capped at 1 MB\n per call, because libraries are inlined into the call, so every byte travels\n every time.\n\n## Build from files, not from the conversation\n\nInline `code` is for a probe or a one-liner. A build is written as files:\n\n- **`scriptPath`** runs an absolute `.jsx` path. The server reads it, so the\n script is paid for once, on disk, rather than once per call in the\n conversation and again on every later request.\n- **`libraries`** takes absolute `.jsx` paths and inlines them ahead of the\n script, in the same scope, so their functions are callable from it. A library\n is re-read and re-evaluated on every call, so keep it to declarations rather\n than to work; it is parse-checked on its own before the script runs, and a\n failure inside one is reported against that file, by name and line.\n\nThe rule: a script file is paid for once; a helper shared by two scripts belongs\nin a library, never pasted into both. `code` and `scriptPath` are exclusive.\n\nPass **`diff: true`** on any script that writes. It fingerprints the comp\naround the call and appends only what changed \u2014 layers added, renamed, retimed,\nkeyframe counts \u2014 so the script reports what it did instead of you reading the\ncomp back, and on a throw the diff rides on the error, which is how you find\nwhere a half-applied script stopped.\n\n## What you already have\n\nBefore you write a helper, check it is not in scope. Each of these wraps a trap\non this page, and a version you write yourself re-derives the bug.\n\n- **`OPS`** \u2014 the whole tool table:\n `OPS.set_transform({compId, layerId, position: [0, 0]})`. Anything a tool\n already does well, do this way rather than reaching into the DOM.\n- **`compById(id)` / `layerById(compOrId, layerId)`** \u2014 the pair every tool\n returns, resolved. No index arithmetic.\n- **`walkProperty(layer, ["Transform", "Position"])`** \u2014 a property path,\n resolved the way the tools resolve it.\n- **`addKeys(prop, [[t, v], \u2026])`** \u2014 returns the key index of each, in order, so\n the next call can ease them without searching.\n- **`ease(prop, keyIndex, easeIn, easeOut)`** \u2014 sizes the KeyframeEase array\n itself; see "Keyframes and easing" below for why that is not optional.\n- **`shape(comp, {name, position})`** \u2014 a shape layer whose origin is `[0,0]`.\n- **`withoutUndoGroup(fn)`** \u2014 closes the undo group around one statement, which\n is what `copyToComp` needs.\n\n## Adobe\'s documentation describes an ExtendScript After Effects 2026 does not implement\n\nEvery item here is measured on 26.3, and several contradict Adobe\'s reference:\n`$.evalFile` evaluates into the calling function\'s scope, not the global one;\n`Error.start` and `Error.end` are `0` on every error, however far in the throw\nwas; an undo group opened in one script call and closed in another is\ndiscarded; `CompItem.posterTime` does not exist; `exportAsMotionGraphicsTemplate`\ninvalidates `app.project` itself, not only the comp passed to it. None of them\nraise \u2014 each returns exactly what success returns. When this page and Adobe\'s\nreference disagree, this page was measured; and before you build on a\ndocumented behaviour nothing here mentions, write the three-line probe that\nproves it.\n\n## Property names that return null\n\n`layer.property(matchName)` returns **null** for a name that does not exist on\nthat layer. It does not throw, so the failure lands later, somewhere else, as\n`null is not an object`. When a lookup might miss, check for null on the line\nthat does it.\n\n- **2D rotation is `ADBE Rotate Z`.** `layer.property("ADBE Rotation")` is\n null; `layer.transform.rotation` is the safe form.\n- **Audio levels are `layer.audioLevels`.** `layer.property("ADBE Audio\n Levels")` is null on an audio layer \u2014 the level is never set, on every layer,\n silently. *The tool already does:* `place_audio_cues`.\n- **Time remap is `layer.timeRemap`.** `layer.property("ADBE Time Remap")` is\n null on audio layers and on precomp layers, even after\n `timeRemapEnabled = true`.\n- **`instanceof` is unreliable on host objects.** Probe instead: a shape layer\n is one where `property("ADBE Root Vectors Group")` is non-null, and a\n project item\'s kind is `item.typeName` \u2014 `"Composition"`, `"Footage"`,\n `"Folder"` \u2014 a string compare that needs no class.\n\n## Reading and writing values\n\n- **`.value` is the value at the comp\'s current time** \u2014 wherever the user\n left the playhead, not time zero \u2014 so on a keyed or expression-driven\n property it is one sample from a moment you did not choose. Read at the\n time you mean with `valueAtTime(t, false)`, the post-expression value\n (`true` is pre-expression). To bake a driven property, sample every time\n you need first, then remove the expression, then the keys: each of those\n changes what the next read returns.\n- **`setValue` on a comp that is not open in a viewer can throw with the\n value already applied.** Open the comp first \u2014 `comp.openInViewer()`, or\n `set_active_comp` from outside \u2014 and on such a throw read the property back\n before retrying: the write may already be there.\n\n## Layers and comps\n\n- **A `comp.layer(n)` reference is index-bound, not a handle.** Once a\n `copyToComp` or a `duplicate()` has shifted the destination\'s indices, a\n reference taken earlier resolves to a *different* layer \u2014 which is how a script\n parents a layer to itself. Re-resolve by id or name after anything that\n inserts a layer.\n- **`copyToComp` does not put the copy at index 1.** The first copy lands on\n top; each later copy lands *below the previous copy*, so `dest.layer(1)` keeps\n handing back the same layer. Identify a copy by diffing the set of layer ids\n before and after the call, or pass `diff: true`.\n- **`copyToComp` needs the undo group closed.** AE refuses to copy a layer that\n has a parent or a linked expression while an undo group is open \u2014 exactly the\n rig worth copying. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`,\n or pass `undoGroup: false` for the whole script and accept whatever undo steps\n AE records on its own.\n- **A copied layer carries parent-relative values.** Parented to a null that\n was not at identity, it renders offset in the destination. Check the first\n frame against the source; fix it with an intermediate null carrying the\n inverse offset, re-parenting while the new parent is at identity.\n- **Set the parent first, the transform second.** Raw `layer.parent = x` does\n not reliably preserve where a layer sits two levels deep, so after scripted\n parenting audit scale and rotation as well as position. *The tool already\n does:* `parent_layer` with `preserveTransform`.\n- **`app.executeCommand(id)` silently no-ops** through this bridge. Menu\n commands depend on host focus and the active selection, and the bridge has\n neither; the call returns without complaint and nothing happens. Use the API\n equivalents \u2014 `CompItem.duplicate()`, `layer.duplicate()`. *The tool already\n does:* `duplicate_comp`, whose `deep: true` also duplicates the nested comps,\n which `CompItem.duplicate()` alone does not.\n- **`moveTo` is a shape-node method, not a layer method.** `layer.moveTo(n)`\n throws `parent is not an INDEXED_GROUP`, naming a concept the caller never\n mentioned. Layers move with `moveBefore` / `moveAfter` / `moveToBeginning` /\n `moveToEnd` \u2014 the first two take a **layer**, never an index, and the two\n directions need different primitives: moving up, `moveBefore` lands on the\n target; moving down the target shifts up as the layer leaves, so `moveAfter`\n is the one that lands on it. *The tool already does:* `reorder_layer`.\n- **Assigning a track matte switches the matte layer\'s video off.** That is\n After Effects\' own default, and `setTrackMatte` and `trackMatteType` both\n do it. A layer that is both a matte and visible art gets `enabled = true`\n again after every call that points a matte at it; read the switch back\n rather than assuming.\n- **`replaceSource` keeps the layer\'s old timing.** `startTime`, `inPoint`\n and `outPoint` stay where the previous source put them, so a longer or\n shorter file arrives trimmed to the old one. After the swap write\n `startTime`, then `inPoint`, then `outPoint`, and read them back; on a\n time-remapped layer turn `timeRemapEnabled` off before the swap and on\n again after it, so the default keys are rebuilt for the new duration.\n- **Removing a comp removes every layer that nests it**, in every comp,\n without a word. The `assembly` topic has the replacement flow.\n- The layer stack builds back-to-front \u2014 every `layers.add*()` lands at\n index 1, on top \u2014 which the `shapes` topic states beside the `Contents`\n order it is the opposite of.\n\n## Effects\n\n- **Effects are addressed by display name, and the name is not unique.**\n `effects.property("Slider Control")` returns the *first* effect with that\n name, so a second slider added under the default name is unreachable by\n name and a write meant for it silently drives the first. Name each control\n as you add it (`fx.name = "Zoom"`), or remove a same-named one first; from\n outside, `list_effects` reports every effect\'s name and index and\n `set_effect_param` takes the index.\n- **Adding an effect invalidates every effect reference held on that layer**,\n a second Slider Control included: the group re-indexes and an earlier\n handle throws `Object is invalid`, the way a shape node reference goes\n stale when a sibling is added. Add every effect first, then resolve each by\n name and set its parameters.\n\n## Shapes\n\nRender order inside `Contents`, references that go stale when a sibling is\nadded, and the coordinate space of a new shape layer bite the tool path as much\nas a script, so they are in the `shapes` topic. The raw-scripting traps:\n\n- **`comp.layers.addShape()` spawns at the comp centre** with the anchor at\n `(0,0)`, so contents authored in comp coordinates land offset by half a frame.\n Zero the position before adding anything to `Contents` \u2014 the `shape()` helper\n does.\n- **Polystar type is `1 = Star`, `2 = Polygon`** on `ADBE Vector Star Type`.\n Type 2 *hides* Inner Radius, so setting it after choosing polygon throws\n `property is hidden`. A gear is type 1.\n- **Stroke dashes do not take.** `addProperty("ADBE Vector Stroke Dash 1")`\n followed by `setValue` throws the same hidden-property error. A dashed band\n that does work: a small square plus a Repeater (`ADBE Vector Repeater\n Transform` \u2192 `ADBE Vector Repeater Position` set to `[2 * side, 0]`) in a\n group in front, over a plain rect in a group behind.\n\n## Text\n\n- **Do not write `justification` into a `TextDocument` from a script.** On\n 26.3, `d.justification = ParagraphJustification.CENTER_JUSTIFY;\n prop.setValue(d)` on a layer made with `addText()` lands as RIGHT (`7414`):\n the enum reads correctly in that scope (`CENTER_JUSTIFY` is `7415`, LEFT\n `7413`), the setter stores the wrong value, and LEFT only appears to work\n because it is `addText()`\'s default. Set font, size, colour and tracking\n through the `TextDocument` if you like, then set justification through the\n tool \u2014 `OPS.set_text({compId, layerId, justification: "center"})` inside the\n script, or `set_text` / `create_text_layer`\'s `anchorAlign` from outside \u2014\n and verify with `get_layer_full({\u2026, include: ["bounds"]})`: a centred layer\'s\n `sourceRect.left` is about `-width / 2`, a left-justified one\'s about `0`.\n The tool path gets a check a script does not: `set_text` and\n `create_text_layer` read the justification back after the write, re-assert it\n once if it moved, and throw naming expected and actual if it still disagrees,\n so the `justification` they return is what the layer shows.\n\n## Keyframes and easing\n\n- **`setTemporalEaseAtKey` wants one `KeyframeEase` per ease dimension, and\n that count belongs to the property, not to its value.** Measured on 26.3:\n Opacity and sliders take 1; a 2D layer\'s **Scale takes 3** while its value\n reads `[x, y]`; a shape\'s **Ellipse Size takes 2** while its value reads\n `[w, h]`; Position and Anchor Point take 1 whether the layer is 2D or 3D,\n because the ease runs along the motion path. Each of those is the opposite of\n the sensible guess \u2014 and one session on 26.3 had a 2D layer\'s Position\n accept only **3** through raw `setTemporalEaseAtKey`, a single observation\n not yet reproduced. The point is that the count is not derivable, from the\n value\'s length or from this list. The wrong one throws `Value array does not\n have N elements` about `parameter 2` and aborts the script there. So never\n size it by hand: `ease(prop, keyIndex, easeIn, easeOut)` is in scope and is\n the same code `set_temporal_ease` runs \u2014 it walks 1, 2, 3, 4 until one takes\n and returns the count that worked; the tools report the same number as\n `easeDimensions`. A bare number means influence; omitting `easeOut` uses the\n same ease both sides. And **put eases after every geometry and key write in\n the script**, so an ease that still fails loses nothing above it.\n- **The key lookup is `nearestKeyIndex(t)`**, not `nearestKeyAtTime`.\n- **Removing the last Time Remap key hides the property.** After\n `while (tr.numKeys) tr.removeKey(1)` the next `setValueAtTime` (or\n `addKeys`) throws `Can not "set value at time" with this property, because\n the property or a parent property is hidden`, the script dies there, and the\n layer is left remap-enabled with no keys. Setting `timeRemapEnabled = true`\n already creates the two keys a loop needs \u2014 `[inPoint, 0]` and\n `[inPoint + source duration, source duration]` \u2014 so there is no reason to\n clear them: edit those keys in place, or put an expression over them \u2014\n `(time - startTime) % thisLayer.source.duration` loops \u2014 which overrides\n them without touching them. If your keys must sit at other times, add them\n **before** removing the two defaults: only a property with no keys at all\n hides. And re-assert `outPoint` after `timeRemapEnabled = true`, because\n enabling it resets the layer\'s end \u2014 on a remapped layer set `outPoint`\n before `inPoint` and read both back, since an in point past the source\'s\n natural end moves the layer instead of trimming it until the out point has\n let it extend. If the keys are already gone, toggle `timeRemapEnabled` off\n and on to get the defaults back. *The tool already does:* `place_audio_cues`\n with `loop: true`.\n\n## Render queue and output modules\n\n- **`OutputModule.setSettings` rejects Format, Channels, Depth and Color as\n read-only** \u2014 `Invalid Value for key: <Depth>. Property is read-only`, and\n likewise `<Channels>`, `<Format>`, `<Color>` \u2014 although\n `getSettings(GetSettingsFormat.SPEC)` lists full enum tables for all four.\n SPEC reports a setting\'s domain, not whether it is writable;\n `GetSettingsFormat.STRING_SETTABLE` is the honest list, and it omits all four.\n Measured on 26.3 from four starting states (after `applyTemplate` of a ProRes\n template, after the stock alpha templates, after a TIFF sequence, and on a\n fresh output module setting Format alone), so it is not codec gating. "Video\n Codec" is not in SPEC at all; it lives behind the QuickTime Format Options\n dialog, which has no scripting surface.\n- **So codec, bit depth and alpha cannot be set from a script.** A person builds\n the output module once in the UI \u2014 Edit > Templates > Output Module > New, or\n Make Template from the Render Queue\'s Output Module menu \u2014 under a known name,\n and scripts (`om.applyTemplate(name)`) and `aerender -OMtemplate "<name>"`\n apply it by name. Templates live in AE\'s preferences, so one creation covers\n every project on the machine. Before trusting a template, apply it and read\n `getSettings(GetSettingsFormat.STRING)` back: an alpha-incapable codec such as\n ProRes 422 HQ reports Channels `RGB` and will not be talked out of it.\n- **A failed `setSettings` throws before any cleanup line runs**, so a temporary\n render-queue item added earlier in the script stays in the user\'s queue. Add\n temporary items inside `try { \u2026 } finally { item.remove(); }`, or sweep the\n queue at the top of the next call.\n\n## Undo groups\n\n- **An undo group does not survive a script boundary.** `beginUndoGroup` in one\n `evalScript` call and `endUndoGroup` in another produces no group at all \u2014\n After Effects discards it, `endUndoGroup()` returns exactly as it does on\n success, and the only place the truth is visible is AE\'s Edit menu. Anything\n spanning calls opens and closes its own group per call. *The tool already\n does:* `run_batch` reports the measured `undoSteps`.\n- **`copyToComp` needs the group closed**, which is the opposite problem \u2014 see\n "Layers and comps".\n\n## The language itself\n\nExtendScript reserves words JavaScript does not: **`short`, `int`, `char`,\n`byte`, `long`, `float`, `double`, `boolean`**. `var short = \u2026` fails with\n`Illegal use of reserved word` and **nothing in the script runs** \u2014 every side\neffect you expected is simply absent, which reads exactly like a bridge failure.\n`s`, `n`, `count`, `flag` cost nothing.\n\nThe rest of the dialect is ES3: no `let`/`const`, no arrow functions, no\ntemplate literals, no `Object.keys`, no destructuring, no trailing commas.\n\n## Reading the result, and a failure\n\n`return X` sends the whole value back \u2014 arrays and nested objects included.\nValues that cannot be represented come back as a marker string in place, never\ndropped: a live AE object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to\npass to `get_layer_full`, not a copy of the layer. An empty result therefore\nmeans the script returned nothing.\n\n**A bare expression is not a return.** `"ping";` as the last line yields\nnothing, and so does any script that just does its work. That case comes back\nas `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the\nscript ran to completion*. Do not re-run it; add an explicit `return` if you\nwant a value.\n\n**On a failure, read the error before you touch anything.** It names the line\nof *your* script and prints that line\'s text; a line inside a library is\nattributed to that file; and when the number cannot be mapped it says so rather\nthan guessing. Everything above that line already ran and nothing rolls back,\nso find out what landed \u2014 `diff: true` appends it to the error; otherwise\n`diff_comp`, `get_comp_tree` or `find_layers` \u2014 and never re-run the script to\nsee whether it fails again: the lines above the failure apply a second time.'
3303
+ },
3304
+ {
3305
+ name: "issue-journal",
3306
+ description: 'What to do when a tool fights back \u2014 the journal entries a failed call brings you, when a workaround is worth writing down with log_issue and which scope it belongs in, retiring an entry that is no longer true, and how to offer the user the chance to pass a problem on without ever saying "GitHub issue". Load when a call has failed in a way you had to work around.',
3307
+ body: '# When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. The\njournal exists so that each one is only paid for once \u2014 and it comes to you,\nrather than you going to it.\n\n## A failure brings its own history\n\n**You do not have to read the journal before you start.** When a call fails,\nthe error itself ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\nfor anything that matches its tool and its error text \u2014 up to three, most\nrecently seen first, the overflow counted \u2014 and names the\n`list_known_issues({id})` call that opens the first. The cause and the\nworkaround are in the entry, not in the index. A match is also a sighting: the\nentry\'s `lastSeen` and `lastVersion` move, so an entry that keeps biting stays\nlive.\n\nReach for the listing when you are planning something that has bitten before\n(`tool` or `query` narrows it) or when the user asks what is known. It is an\nindex \u2014 id, scope, title, tools, `kind`, `lastSeen`, `lastVersion`, counts \u2014\nwith the entries naming the tool first and then the most recently seen first,\nand a `next` pointer spelling out the call that opens the top one. Archived\nentries \u2014 unseen for 30 days, a `tool-bug` last seen on an older server than\nthe one running, or retired with `archive_issue` \u2014 are hidden and counted in\n`archivedCount`; `includeArchived: true` shows them, and a read by `id` always\nworks, archived or not, because a failure\'s pointer can name one (marked\narchived, with the reason).\n\nThere are two journals and every entry says which it came from. `project` is\nthis project\'s own notes; `user` travels with the person across every project.\nIds are only unique within a journal, so open an entry with the qualified form\nthe listing\'s `next` pointer shows you \u2014 `list_known_issues({id: "user:\u2026"})`.\n\n## Write it down the moment you work it out\n\n**`log_issue`** records what you learned. Log something when all three are\ntrue: it cost real effort, it was the tool\'s fault rather than yours, and the\nnext session would hit it too. A schema that accepts an argument AE then\nrejects, an error message that names the wrong thing, a property whose real\nname is nothing like its display name. Not your own typos. Not "I forgot the\nlayer was 3D".\n\nWrite the entry for someone who has not seen the failure: the call that\nproduced it, the exact error text, and a workaround concrete enough to apply\ndirectly. **Pass `tools` and the exact `errorText`.** That is what lets the\nnext failure be answered with your entry, and it is what folds a re-log of the\nsame error into the existing entry even if you gave it a different title \u2014 the\nresult says `mergedBy: "errorText"` and keeps the existing title. Logging under\nan existing title extends that entry too, so if the failure brought you an\nentry, extend it rather than writing a twin. Use `kind: "ae-quirk"` for a\npermanent After Effects behaviour, so a new release does not archive it as\nfixed; the default `tool-bug` is presumed fixed once it was last seen on an\nolder server than the one running. Every entry records the server version it\nwas first and last seen on. If the result says `reopened: true`, a problem\nthat was supposed to be gone is back \u2014 tell the user.\n\n**Pick the scope by what the entry is about, not by where you are.** Leave it\nat the default `project` for this project\'s footage, comps or files. Pass\n`scope: "user"` when it is about how these tools or After Effects behave \u2014 that\nis nearly everything worth logging, and it is the difference between the next\nproject starting out knowing it and re-learning it.\n\n## Retire what is no longer true\n\nAn entry has to be able to leave, and most leave on their own: one unseen for\n30 days, or a `tool-bug` last seen on an older server than the one running, is\narchived without anyone deciding \u2014 and comes back the moment a failure matches\nit or a `log_issue` lands on it. `archive_issue({id, reason})` is for a\ndecision: a report already covers the problem (pass its URL as the reason), a\nrelease fixed it, or the lesson has been promoted into the\n`extendscript-gotchas` topic or the project\'s own docs. An archived entry stays\non disk, leaves the index, and is still named by a matching failure \u2014 marked\narchived, with the reason \u2014 so the pointer leads somewhere; only a deliberate\n`log_issue` reopens it. A journal that only grows is one nobody reads.\n\n## Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it checks\nthe tracker for a report that already covers the problem before drafting one,\narchiving the entry when that report is closed and marking it reported when it\nis open, and handles the rest.\nIf they say no, drop it; the note stays and can be offered again another time.\n\nNever claim you have reported something you have not.'
3308
+ },
3309
+ {
3310
+ name: "mogrt-and-footage",
3311
+ description: "Exporting a Motion Graphics template, importing footage and keeping the project bin clean through the AE MCP tools \u2014 export_mogrt and the preconditions it checks before touching the project, the thumbnail, the modal dialogs that freeze this connection, the SVG whose viewBox makes After Effects fabricate dimensions and render nothing, and the solids a deleted comp leaves behind. Load when a task exports a .mogrt, imports a file, or deletes comps.",
3312
+ body: "# Motion Graphics templates and footage\n\n## Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate`\nfrom `run_jsx`: that call raises modal dialogs, and a modal dialog freezes this\nwhole connection until someone clicks it in After Effects \u2014 but suppressing\nthem, which is what you have to do, costs you the only channel AE has for saying\nwhy an export failed. It answers with a bare boolean and nothing else. So the\ntool checks every precondition it can *before* exporting, which is the half of\nthe job a script cannot do.\n\n`export_mogrt` saves the project first (which removes AE's \"the project needs to\nbe saved\" prompt, and has to happen per export because exporting dirties the\nproject again), suppresses the font warning, and runs outside the undo group so\nthere is no \"undo group mismatch\" afterwards. Measured on 26.3: suppressed, an\nexport of a comp using a non-Adobe font returns in about three seconds;\nunsuppressed, the same export blocks past sixty and writes nothing until the\ndialog is clicked.\n\nFour things to know before you call it. The first is the one that actually\nstops exports:\n\n- **The comp needs at least one property in its Essential Graphics panel.**\n After Effects will not build a template from an empty one, and it refuses\n silently \u2014 no file, no dialog, not a word. The tool checks the controller\n count first and refuses before touching anything. The fix is in AE and the\n user has to do it: Window > Essential Graphics, pick the comp, drag a layer\n property in.\n- **The project must have been saved once, by hand.** There is no folder to\n save into otherwise, and the tool refuses rather than raising a dialog the\n user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE's own\n default is the literal `Untitled` \u2014 leave it to AE and every template in the\n project overwrites the same file. `overwrite: true` is required to replace an\n existing one.\n- **`fonts` in the result lists what the template will require.** Tell the user\n about any non-Adobe ones: Premiere flags the template as needing fonts it\n cannot supply, and that is worth hearing from you rather than discovering\n later.\n\n**If an export fails anyway, read the message rather than guessing.** It lists\nwhat was checked and ruled out, and it only names a modal dialog when dialogs\nwere left *unsuppressed*. Under the default suppression a dialog is impossible\nby construction, so the cause is genuinely unknown \u2014 say so, and tell the user\nthe one place AE's own reason exists: exporting the same comp by hand from the\nEssential Graphics panel, where AE shows its error in the interface. Do not\nsend them looking for a dialog that cannot be there.\n\n**The thumbnail.** AE writes the comp's *first frame* into the template, so\nanything that fades up from nothing gets a black one. Pass `posterTime` with a\nmoment that actually shows the design and it is rendered and swapped in. If\nonly the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched`\nin the result.\n\n`comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in\n**reverse order of addition**: index 1 is the one added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a\ncrash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from\n`run_jsx` raises the save prompt the same way; use `screenshot_frame`, which\ndoes not.\n\n## Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in\na comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails\nsilently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports\nwith **fabricated dimensions and renders as nothing**, no error at any stage.\nMeasured on 26.3: that viewBox yields a 15906\xD75654 item that will not even\nrasterize, and the numbers depend only on the viewBox. The tool compares the\naspect ratio the file asks for against the one AE produced, and on a mismatch it\ndeletes the item and throws, rather than handing you an asset that looks healthy\nin the project panel and renders empty.\n\nIf you hit that, the workarounds are:\n\n- **Simple flat SVGs** \u2014 rebuild the path as a shape layer with the real\n vertices, scaled down to a sane coordinate space (divide by `333.334` for a\n 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule`\n to `2` when the SVG says `fill-rule=\"evenodd\"`. Done this way the result is\n pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to\n a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of\nthrowing. It is for when you know the dimensions are wrong and want it anyway \u2014\nnot a way past the error.\n\n## Solids outlive their comps\n\nA solid or adjustment layer's source is a footage item in the project's Solids\nfolder, and `delete_comp` and `delete_layer` remove the layer, not the item \u2014\nso iterating on a rig by building and deleting comps silently fills the bin.\n`delete_comp` counts what it left in `unusedSolidsLeft`, with a note when that\nis not zero. Pass `purgeUnusedSolids: true` to take the comp's own now-unused\nsolids with it in the same undo step, reported as `removedSolids` and\n`keptSolids` \u2014 a solid another comp still uses is kept, with that comp named\nunder `usedIn`. Only the deleted comp's own solids are ever considered there;\n`purge_unused_footage` sweeps the whole project: solids only by default,\n`solidsOnly: false` for every unused footage item (what AE's own Remove Unused\nFootage does), `folderId` to scope it to one folder, and `dryRun: true` \u2014\nfirst, on a project you did not build \u2014 which answers `wouldRemove` without\nremoving anything or adding an undo step. Neither ever removes a solid another\ncomp still uses, or a nested comp: a comp nothing uses is not footage. A comp\nanother comp *does* nest is the opposite case \u2014 deleting it takes the nesting\nlayers with it \u2014 and the `assembly` topic has the replacement flow."
3313
+ },
3314
+ {
3315
+ name: "shapes",
3316
+ description: "Shape layers through the AE MCP tools \u2014 the origin a new shape layer spawns at and why it is the comp's coordinate space, add_shape_content and its all-or-nothing contract, one fill per group, the render order inside Contents that runs opposite to the layer stack (which builds back-to-front), node references that go stale, a group's pivot, seams between shapes, centring a repeated run, and reading a shape layer cheaply. Load when a task builds or edits a shape layer.",
3317
+ body: '# Shape layers\n\n## Where a new shape layer\'s origin is\n\n`create_shape_layer` puts the new layer\'s origin at `[0,0]` with its anchor at\n`[0,0]`, so **the layer\'s coordinate space is the comp\'s**: every vertex, rect\nposition and path you add afterwards is in comp pixels, the same space as the\ncomp size, another layer\'s position, `sourceRect` and a screenshot. After\nEffects\' own spawn point is the comp centre, which silently offsets a drawing\nauthored in comp coordinates by half a frame; pass `position: "center"` if you\nwant that back, or any `[x, y]` to place the origin yourself. The result echoes\nthe position and anchor it ended up with, so nobody has to render a frame to\nlearn the coordinate system.\n\n## Adding contents\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`,\n`ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`,\n`group`. Properties are set with friendly names in the same call (`size`,\n`position`, `roundness`, `color`, `width`, `lineCap`, \u2026). For a custom path,\n`{type: "path", vertices: [[x, y], \u2026], closed: true}` \u2014 the key is `vertices`,\nnot `points`. `set_shape_path` replaces a path\'s vertices and tangents later;\n`set_shape_property` sets one property on a node, optionally as a keyframe.\n\nThe tool is **all-or-nothing**: if a key cannot be applied, the whole node is\nremoved and the error names the bad keys, so a success result means everything\nlanded. Do not add defensive re-reads for it \u2014 but do read the error, since it\nusually means the property is named differently on that node type, and a\n`get_layer_full({\u2026, include: ["shape"]})` shows the real name.\n\n**A fill or stroke applies to every path above it in its group.** One Fill\nunder two paths fills both, so an open stroked path beside a filled shape in\nthe same group renders as a filled polygon. One group per path-and-paint\npairing: `{type: "group"}` first, then the path and its fill or stroke inside\nit through `parentGroupPath`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1\nrenders in *front*, and each `add_shape_content` call appends behind the\nprevious one. So build **front-to-back**: details, text plates and\ntraffic-light dots first, the big background rectangle last. Getting it\nbackwards is silent \u2014 no error, just a solid slab where the artwork should be.\n`zOrder: "front"` places a node at index 1 for you, but it needs an internal\n`moveTo`, which disturbs *nested* renders of the comp on AE 26.3; prefer\nordering the calls. If existing content is in the wrong order, rebuild it rather\nthan reordering, and verify with a screenshot of a comp that **nests** it, not\nonly the comp that owns it. None of this applies to the layer stack: moving\nwhole layers is `reorder_layer`, a different mechanism with none of these\ncaveats. The stack runs the other way: every new layer \u2014 `create_*`,\n`duplicate_layer`, a `layers.add*()` in a script \u2014 lands at index 1, on top,\nso a stack is built **back-to-front**, the plate first and the label after,\nand a script re-run from the middle re-stacks whatever it recreates over what\nwas already there.\n\n**Node references go stale.** Adding a sibling to a group invalidates a\nreference you already hold to another node in it \u2014 add a Stroke and an earlier\nFill reference starts throwing `Object is invalid`. Add every node first, then\nset values and expressions by addressing nodes by name. The tool re-fetches\nafter every insert for the same reason.\n\n## Animating a group\n\nA group drawn in comp coordinates pivots at the comp origin: its Transform\'s\nanchor point is `[0,0]`, which with the layer\'s origin at `[0,0]` is the comp\'s\ntop-left corner, so a scale or rotation keyed on the group swings the artwork\naround that corner. Before keying a group, set its anchor point to the pivot\nyou mean *and* its position to the same value \u2014 `set_shape_property` with\n`contentPath: ["Contents", "<group>", "Transform"]` and `property: "Anchor\nPoint"`, then `"Position"` \u2014 which leaves the artwork where it is and turns it\nabout the pivot.\n\n## Seams and runs\n\n**Two shapes that meet on a shared edge show the seam when they move.**\nAnti-aliasing leaves both edges partly transparent, so a hairline of whatever\nis behind flickers along the join under a camera move, worst over a dark\nground. Overlap the back shape 10\u201320 px under the front one rather than\nbutting them.\n\n**Centring a repeated run** \u2014 a Repeater tape, a dashed band \u2014 of copies `s`\nwide at pitch `p` across a width `W` starting at `left`:\n`copies = floor((W \u2212 s) / p) + 1`, and the first copy\'s centre is\n`left + (W \u2212 s \u2212 (copies \u2212 1)\xB7p) / 2 + s/2`. With that `copies` on the\nRepeater and that centre as the first element\'s position the run is\nsymmetrical whatever `W` is; dashes with equal gaps are `p = 2s`.\n\n## Reading a shape layer\n\nUse `shapeDetail: "compact"` on `get_layer_full`. It returns one indented line\nper group \u2014 the group\'s name, its matchName, then its own properties as\n`name=value`, with `[3 keys]` or `[expr]` on the animated ones and\n`(at defaults)` for a group Transform nobody has touched. Every name the write\ntools address a node by is still on the line, at a fraction of the cost of the\nJSON form. Reach for `"full"` when you need exact values, keyframe detail or\nindices, and `shapeDepth` to stop the walk at a depth on a heavy layer.\n\nOne thing is left out of both forms: **Material Options**, the 48-property 3D\nextrusion block AE hangs off every vector group. It only means anything for an\nextruded shape under the Cinema 4D renderer, and on a 2D shape layer it is most\nof the weight of the read \u2014 a single 68px circle costs 4,400 tokens with it, of\nwhich the geometry is about 40. `materialsOmitted` counts what was skipped;\n`shapeMaterials: true` brings it back.\n\n## Scripting shapes by hand\n\nThe raw-scripting traps \u2014 the spawn point of `addShape()`, polystar types that\nhide their own properties, stroke dashes that refuse to take \u2014 are in the\n`extendscript-gotchas` topic, along with the `shape()` helper that lands a\nscripted layer at `[0,0]`.'
3318
+ },
3319
+ {
3320
+ name: "sound",
3321
+ description: "Sound effects and beds through the AE MCP tools \u2014 place_audio_cues for a whole cue list in one undo step, what levelDb actually means and why a level copied from another cue is meaningless, peak for a one-shot and RMS for a bed with the onset on the beat, loops, fades and stretch per cue, a trim clamped at the file's end, a bed that follows a move, the dry run, a file that will not place, and the audio facts a hand-written loop gets wrong. Load when a task places, levels or times sound.",
3322
+ body: "# Sound\n\n`place_audio_cues` scores a scene in one call: a list of cues \u2014 each a file (or\nan already-imported `footageId`), a comp `time` and a `levelDb` \u2014 becomes one\naudio layer each, imported once however many cues name the same file, named,\ntrimmed, labelled, in a single undo step. Reach for it the moment you are\nplacing more than two or three sounds; the alternative is dozens of round trips\nor a `run_jsx` loop that has to know that `layer.property(\"ADBE Audio Levels\")`\nreturns null on an audio layer.\n\nIt is **all-or-nothing**: every cue is checked (the file exists, the item has\nan audio track, the time is inside the comp) before a single layer is made, and\nif a later one still fails, everything the call created is removed and the\nerror names the cue by index. `dryRun: true` checks a list against the project\nwithout importing, creating, or even adding an undo step, and answers with\ncounts, `wouldImport`, `wouldReuse` and the failing cues by index \u2014 never the\nresolved list. Each cue names its sound with exactly one of `path` or\n`footageId`; `inPoint` and `outPoint` trim in **comp** time, not file time. An\n`outPoint` past the file's placed length \u2014 its duration times `stretch` \u2014 is\nclamped to it without a word, so read the out point back from the result; a\nsound that must run longer is `loop: true`, not a longer trim.\n\n## Levels\n\n`levelDb` is decibels, After Effects' own unit: **`0` is the file as recorded**,\nnegative is quieter, and it is written explicitly even when you omit it. Two\nthings follow:\n\n- **A level copied from another cue is meaningless.** Files in one sound bank\n routinely differ by 30 dB as recorded \u2014 a whoosh mastered hot and a room tone\n mastered quiet \u2014 so `-6` on one is nothing like `-6` on the other. Measure\n before you set: peak and RMS of the file (`ffmpeg -af volumedetect`, `sox\n stat`, or whatever the shell has; where there is no shell, ask the user what\n the file sounds like against the others), then choose a level that brings it\n to where you want it, not a number that looked right last time.\n- **Read the level back off the layer.** The result carries one small entry\n per cue \u2014 `layerId`, `name`, `time`, and what an option changed \u2014 and does\n not echo the level; `run_jsx` with\n `return layerById(compId, layerId).audioLevels.value` reads it. Ask the user\n to preview if they can \u2014 a level is the one property in this toolset that no\n screenshot can check.\n- **Match a one-shot by peak and a bed by RMS.** A hit is heard at its peak\n and a bed at its average, so a bed levelled by peak sits too low and a hit\n levelled by RMS jumps out. And put the file's *onset* on the beat, not its\n start: a one-shot with silence before the hit goes at `time: beat \u2212 onset`\n with `inPoint: beat` to trim the silence, which is also how a hit is\n shortened under a visual shorter than the file.\n\n## Loops, fades and stretch\n\nThree per-cue options cover what a scoring pass otherwise redoes in `run_jsx`,\nand each moves something After Effects then quietly resets \u2014 which is the\nreason to take them from the tool rather than script them:\n\n- **`loop: true`** repeats the sound to the cue's `outPoint`, or to the end of\n the comp when there is none \u2014 beds and ambiences. It enables time remapping\n with a wrap-around expression on Time Remap, keeps the two keyframes AE\n creates (removing them hides the property), and re-asserts the out point,\n because enabling remapping resets it. `inPoint` still trims the front of the\n file the way it does on any layer.\n- **`fadeIn` / `fadeOut`**, in seconds, keyframe Audio Levels at the in and\n out point the layer actually has, between the call-level `fadeFloorDb`\n (default `-48`, the bottom of AE's own slider) and the cue's `levelDb`. A\n fade that does not fit the cue's placed length, or a floor at or above the\n cue's level, is refused by cue index with nothing placed; for a file not yet\n imported the fit is checked right after the import, before any layer exists.\n The result carries `fadeFloorDb` when any cue faded.\n- **`stretch`** is a percentage: `100` unchanged, `200` half speed at a lower\n pitch. The start time and any trims are re-asserted after it, because AE\n moves them when stretch changes. A loop that is also stretched has the factor\n baked into its expression, so changing the stretch by hand afterwards leaves\n the loop wrapping at the old rate \u2014 set it here, or place the cue again.\n\nEvery option is validated with the rest of the list before anything is\ncreated, and the result names what an option changed \u2014 `looped`, `stretch`,\n`fadeIn`, `fadeOut`, and the in and out point read back \u2014 only on the cues it\napplied to.\n\n## Timing\n\nCue times are comp times, and a cue placed in a shot comp is at that comp's\nlocal time, so a cue list for a master and a cue list for a shot are different\nlists \u2014 put a sound where the thing that makes it lives. Comp markers at the\nbeats (the `assembly` topic) are the right skeleton for a cue list, and a cue's\n`name` is worth setting to the beat it belongs to.\n\nA sound that belongs to a move follows the move. Under an eased camera, a bed\nrun at a constant rate and level comes apart from the picture at every ease;\ndrive the bed's Time Remap from the distance the camera has travelled and its\nlevel from the camera's `speed` (expressions on the audio layer reading the\nnull's position), so it slows, stops and rises with the move.\n\n## A file that will not place\n\nA file that imports with no audio track, or refuses to import at all, is worth\nchecking for its sample format before anything else: a 32-bit float WAV\n(format tag 3) is the usual case. Convert it to 16-bit PCM \u2014 `afconvert -f\nWAVE -d LEI16 in.wav out.wav` on macOS, `ffmpeg -i in.wav -c:a pcm_s16le\nout.wav` anywhere \u2014 and keep a sound bank at 16-bit PCM so it never comes up.\n\n## Scripting sound by hand\n\nThe raw-scripting facts \u2014 `layer.audioLevels` rather than a property lookup,\n`layer.timeRemap` for a loop, and the Time Remap keys that must not be cleared\n\u2014 are in the `extendscript-gotchas` topic."
2718
3323
  },
2719
3324
  {
2720
3325
  name: "style-guide",
2721
3326
  description: "Help a motion designer capture their house style \u2014 palette, type, motion and layout \u2014 into the house-style.md file that sits next to their After Effects project and shapes everything built afterwards. Load when the user asks to create, edit or review their style guide, when they say work does not look like theirs, or when get_house_style reports none exists.",
2722
- body: '# Capturing a house style\n\nA house style is the difference between an assistant that builds *a* lower third\nand one that builds *their* lower third. It lives in `house-style.md` beside the\n`.aep` file, and `get_house_style` reads it before any build task.\n\nYour job here is to get one written with as little effort from the user as\npossible. They are a motion designer. They know exactly what their work looks\nlike and will struggle to dictate it as a specification \u2014 so do not ask them to.\n\n## Two ways in. Prefer the first.\n\n### 1. Read it off work they already like\n\nThis is far better than any questionnaire, because it produces real numbers\ninstead of adjectives.\n\n1. Ask which comp to learn from \u2014 "point me at something that looks the way you\n want everything to look."\n2. `get_comp` for size and frame rate, then `get_layer_full` on the layers that\n carry the look: the text, the background, the accent shapes.\n3. Pull out the concrete values \u2014 hex colours, font families and sizes, tracking,\n corner radii, stroke widths, the position of things relative to the frame.\n4. Read the keyframes too. `get_keyframes` plus the ease settings tell you the\n timing signature: how long a standard in-animation takes, whether it\n overshoots, whether anything is ever linear.\n5. Show them what you found, in their language, and ask what to change:\n\n > Here\'s what I read off that comp: near-black background `#0B0D12`, white\n > text in Inter Semibold at 64px with slightly tight tracking, one green\n > accent `#3DC46E`. Things scale in over about 0.4s with an overshoot to 108%\n > and easy ease on both ends. Nothing sits perfectly still \u2014 there\'s a slow\n > wiggle on the chip. Does that sound like your style, or was that comp a\n > one-off?\n\n6. Write it with `set_house_style`.\n\n### 2. Ask, when there is nothing to read\n\nOnly if the project is empty or they have no reference. Keep it to four\nquestions, and offer concrete options rather than open ones \u2014 "dark or light\nbackground?" beats "what\'s your palette?". Then build one small example, show it\nwith `screenshot_frame`, and refine from their reaction. Reacting is easier than\nspecifying.\n\n## What makes a guide that actually works\n\n**Numbers, not adjectives.** `#131521 at 92% opacity` is usable. "Dark and clean"\nis not. If a corner radius, a stroke width or a hold duration matters, write the\nnumber. Anything vague will be silently reinterpreted every time it is read.\n\n**Rules, not just values.** The most valuable lines are the prohibitions: "never\nput text directly on footage \u2014 always on a rounded chip", "keep total runtime\nunder 8 seconds", "no linear motion unless something mechanical is moving".\nThose are what stop work drifting.\n\n**Only what you verified.** Do not pad the file with plausible-sounding defaults\nthey never asked for. A short guide that is true beats a complete one that is\nhalf invented. Leave a heading empty rather than filling it with a guess.\n\n## Keep it current\n\nWhen the user corrects the same thing twice \u2014 "no, the accent green, not the\nblue" \u2014 that is a missing rule, not a one-off. Offer to add it:\n\n> I\'ve had to switch that green twice now. Want me to put it in the style guide\n> so it\'s the default from here?\n\nRead the existing guide with `get_house_style` before writing, and preserve what\nis already there. `set_house_style` replaces the whole file, so send back the\nfull document, not just your additions.\n\n## The one thing to warn them about\n\n`house-style.md` is written next to the `.aep`, so **the project has to have been\nsaved at least once** \u2014 an unsaved project has no folder to write into, and\n`get_house_style` will say so. If that happens, ask them to save the project\nfirst, then write the guide.\n\nThe file is plain markdown. Tell them where it is and that they can edit it in\nany text editor without going through you.\n\n## Starting point\n\nWhen writing a guide from scratch, this is the shape to fill in. Drop headings\nyou have nothing real to put under.\n\n```markdown\n# House style\n\n## Palette\n| Role | Colour | Notes |\n|---|---|---|\n| Background | `#0B0D12` | |\n| Primary text | `#FFFFFF` | |\n| Accent | `#3DC46E` | Emphasis and positive values |\n| Negative | `#E03333` | |\n\n## Type\n- Headings: Inter Semibold, 56\u201372px, tracking -10\n- Body: Inter Regular, 28\u201334px\n- Left-aligned unless stated otherwise\n\n## Motion\n- Standard in: scale 0 \u2192 108 \u2192 100, easy ease, ~0.4s\n- Standard out: scale \u2192 0, ~0.3s\n- Easy ease on everything; no linear motion unless mechanical\n- Subtle wiggle on position so nothing sits perfectly still\n\n## Layout\n- 1920\xD71080 at 30fps\n- 120px safe margin from every edge\n- Lower thirds sit bottom-left, above the margin\n\n## Rules\n- Never put text directly on footage \u2014 always on a rounded chip\n- Total runtime under 8 seconds\n```'
3327
+ body: '# Capturing a house style\n\nA house style is the difference between an assistant that builds *a* lower third\nand one that builds *their* lower third. It lives in `house-style.md` beside the\n`.aep` file, and `get_house_style` reads it before any build task.\n\nYour job here is to get one written with as little effort from the user as\npossible. They are a motion designer. They know exactly what their work looks\nlike and will struggle to dictate it as a specification \u2014 so do not ask them to.\n\n## Two ways in. Prefer the first.\n\n### 1. Read it off work they already like\n\nThis is far better than any questionnaire, because it produces real numbers\ninstead of adjectives.\n\n1. Ask which comp to learn from \u2014 "point me at something that looks the way you\n want everything to look."\n2. `get_comp` for size and frame rate, then `get_layer_full` on the layers that\n carry the look: the text, the background, the accent shapes.\n3. Pull out the concrete values \u2014 hex colours, font families and sizes, tracking,\n corner radii, stroke widths, the position of things relative to the frame.\n4. Read the keyframes too. `get_keyframes` plus the ease settings tell you the\n timing signature: how long a standard in-animation takes, whether it\n overshoots, whether anything is ever linear.\n5. Show them what you found, in their language, and ask what to change:\n\n > Here\'s what I read off that comp: near-black background `#0B0D12`, white\n > text in Inter Semibold at 64px with slightly tight tracking, one green\n > accent `#3DC46E`. Things scale in over about 0.4s with an overshoot to 108%\n > and easy ease on both ends. Nothing sits perfectly still \u2014 there\'s a slow\n > wiggle on the chip. Does that sound like your style, or was that comp a\n > one-off?\n\n6. Write it with `set_house_style`.\n\n### 2. Ask, when there is nothing to read\n\nOnly if the project is empty or they have no reference. Keep it to four\nquestions, and offer concrete options rather than open ones \u2014 "dark or light\nbackground?" beats "what\'s your palette?". Then build one small example, show it\nwith `screenshot_frame`, and refine from their reaction. Reacting is easier than\nspecifying.\n\n## What makes a guide that actually works\n\n**Numbers, not adjectives.** `#131521 at 92% opacity` is usable. "Dark and clean"\nis not. If a corner radius, a stroke width or a hold duration matters, write the\nnumber. Anything vague will be silently reinterpreted every time it is read.\n\n**Rules, not just values.** The most valuable lines are the prohibitions: "never\nput text directly on footage \u2014 always on a rounded chip", "keep total runtime\nunder 8 seconds", "no linear motion unless something mechanical is moving".\nThose are what stop work drifting.\n\n**Only what you verified.** Do not pad the file with plausible-sounding defaults\nthey never asked for. A short guide that is true beats a complete one that is\nhalf invented. Leave a heading empty rather than filling it with a guess.\n\n## Keep it current\n\nWhen the user corrects the same thing twice \u2014 "no, the accent green, not the\nblue" \u2014 that is a missing rule, not a one-off. Offer to add it:\n\n> I\'ve had to switch that green twice now. Want me to put it in the style guide\n> so it\'s the default from here?\n\nRead the existing guide with `get_house_style` before writing, and preserve what\nis already there. `set_house_style` replaces the whole file, so send back the\nfull document, not just your additions.\n\n## The one thing to warn them about\n\n`house-style.md` is written next to the `.aep`, so **the project has to have been\nsaved at least once** \u2014 an unsaved project has no folder to write into, and\n`get_house_style` will say so. If that happens, ask them to save the project\nfirst, then write the guide.\n\nThe file is plain markdown. Tell them where it is and that they can edit it in\nany text editor without going through you.\n\n## The digest contract\n\n`get_house_style` answers with a digest by default, and the digest is built by\na parser that recognises a fixed vocabulary. **The template\'s headings are the\ncontract.** A guide written under them comes back `structured: true` with every\nsection read; a guide written under other headings comes back with those\nsections named in `sectionsOmitted`, or \u2014 when nothing at all is recognised \u2014\n`structured: false` with the document\'s opening text and no palette, which the\nnext session will read as "this project has no rules". So write the guide in\nthe shape below, and when the user\'s own guide uses different headings, tell\nthem which ones were skipped rather than letting them believe it was read.\n\nWhat the parser recognises:\n\n- **Headings** \u2014 markdown `#` headings of any level, and the underlined\n (`===` / `---`) forms. A heading is classified by the words in it:\n *palette, colour, color, swatch* \u2192 palette; *type, typography, font,\n lettering, typeface* \u2192 type; *motion, animation, timing, easing, ease,\n transition* \u2192 motion; *layout, grid, spacing, composition, margin, safe area,\n framing, rule, constraint* \u2192 layout. "Rules" is read as layout on purpose.\n A subsection under a classified heading is folded into it (`### Display`\n under `## Type`); a top-level title such as `# House style` with no prose of\n its own is fine.\n- **Colours** \u2014 every hex (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) anywhere in\n the document, not only under Palette. It is named by the text before it on\n the line, or by the first non-hex cell of its table row, so `Accent: #3DC46E`\n and a table row whose first cell is *Accent* both come back as *Accent*. A\n colour written as `rgb(\u2026)` or by name is not a palette entry.\n- **Size** \u2014 up to 24 colours, 3 type lines, 8 layout lines and about 240\n characters of motion. Put the lines that matter most first under each\n heading; what is dropped is counted in the digest\'s `note`.\n\n## Starting point\n\nWhen writing a guide from scratch, this is the shape to fill in \u2014 its headings\nare the ones the digest reads. Drop headings you have nothing real to put\nunder.\n\n```markdown\n# House style\n\n## Palette\n| Role | Colour | Notes |\n|---|---|---|\n| Background | `#0B0D12` | |\n| Primary text | `#FFFFFF` | |\n| Accent | `#3DC46E` | Emphasis and positive values |\n| Negative | `#E03333` | |\n\n## Type\n- Headings: Inter Semibold, 56\u201372px, tracking -10\n- Body: Inter Regular, 28\u201334px\n- Left-aligned unless stated otherwise\n\n## Motion\n- Standard in: scale 0 \u2192 108 \u2192 100, easy ease, ~0.4s\n- Standard out: scale \u2192 0, ~0.3s\n- Easy ease on everything; no linear motion unless mechanical\n- Subtle wiggle on position so nothing sits perfectly still\n\n## Layout\n- 1920\xD71080 at 30fps\n- 120px safe margin from every edge\n- Lower thirds sit bottom-left, above the margin\n\n## Rules\n- Never put text directly on footage \u2014 always on a rounded chip\n- Total runtime under 8 seconds\n```'
3328
+ },
3329
+ {
3330
+ name: "text",
3331
+ description: "Text layers through the AE MCP tools \u2014 alignment as live paragraph justification rather than a measured anchor, tracking set explicitly so the Character panel cannot leak in, what set_text controls, and sizing a background to the text. Load when a task creates, restyles or lays out a text layer.",
3332
+ body: '# Text layers\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph\njustification** and leaves the anchor point at `[0,0]`, so `position` is the\nstart of the first baseline. Pass `"center"` or `"right"` for those, `"none"`\nfor AE\'s raw behaviour. Because the alignment is justification rather than a\nmeasured offset, it stays correct when the text changes later \u2014 retyped, driven\nby an expression, or edited through Essential Graphics in Premiere. Never "fix"\nalignment by writing an anchor point computed from `sourceRectAtTime()`: it is\nright once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise\ninherits whatever the user\'s Character panel was last left on, and the same\ncall then renders differently on two machines.\n\n`set_text` controls the text and its styling \u2014 font, size, fill and stroke,\ntracking, leading, justification, faux bold and italic, caps, baseline shift.\n`add_text_animator` adds an animator group (position, scale, rotation, opacity,\ntracking, skew, fill and stroke colour) with an optional range selector, which\nis how per-character animation is built.\n\n**Verify alignment by the bounds, not by eye.** `get_layer_full({\u2026, include:\n["bounds"]})` returns `sourceRect`: a centred layer\'s `left` is about\n`-width / 2`, a left-justified layer\'s about `0`, a right-justified layer\'s\nabout `-width`. That is the check to make after a script has touched a\n`TextDocument`; the tools check their own writes, below.\n\n**To fit a background to text**, read `sourceRect` from the same call and size\nthe shape from its width and height plus padding, positioned from its `left`\nand `top`. A `sourceRectAtTime()` expression on the background does the same\nthing live, and stays right when the text is retyped.\n\n**The justification you got is the one in the result, not the one you asked\nfor.** On 26.3 the Source Text round trip can hand back a different alignment\nfrom the one written \u2014 a left-aligned layer re-centred by a `set_text` that\nchanged only `text`. So `set_text` and `create_text_layer` read the\njustification back after every write, write it once more on its own if it\nmoved, and throw naming expected and actual if it still disagrees;\n`create_text_layer` removes the layer first, so a failure never leaves a layer\nyou have no id for. Both results carry `justification` as a name and\n`justificationReasserted`, and an unknown justification name is refused rather\nthan ignored. Trust `justification` in the result over your own argument.\n\n**From `run_jsx`, set justification through the tool** \u2014\n`OPS.set_text({compId, layerId, justification: "center"})` inside the script \u2014\nrather than by assigning `TextDocument.justification`, which on 26.3 stores the\nwrong value and gets no read-back. The `extendscript-gotchas` topic has the\nmeasurement under Text.'
2723
3333
  },
2724
3334
  {
2725
3335
  name: "whats-new",
2726
- description: "What changed in the After Effects tools recently, newest first \u2014 the behaviour differences worth knowing if you last used an older build, and the version gate you will meet after an upgrade. Read it when a call behaves differently from what you expected, or when the user says a tool used to do something else.",
2727
- body: '# What changed\n\nRead this when something behaves differently from what you expected, or when the\nuser tells you a tool used to work another way. It is the release notes an agent\nneeds rather than the ones a human reads.\n\n**When this topic and a tool\'s own schema disagree, believe the schema.** It is\nrefreshed at each release and a build in between can be ahead of it.\n\n## 0.4.0\n\nThe release where verifying a change stopped meaning reading the whole thing\nback \u2014 and where a live pass against After Effects 2026 found four things this\nserver had been claiming that were never true. Those are first, because an agent\nthat learned the old story will otherwise repeat it to a user.\n\n### Things that were never true, and are now fixed\n\n- **`run_batch` was never one undo step, and over 500 ops it still is not.** The\n guarantee it shipped with did not exist on either path: After Effects discards\n an undo group opened in one script call and closed in another, so a 600-op\n batch landed as about six hundred separate steps while reporting one. Now: up\n to 500 ops is genuinely one step; over 500 is **one step per chunk of 25**,\n around 24 for 600 ops, with the measured count in `undoSteps` and a `note`\n saying it in words. `singleUndo: true` forces one step up to 2000 ops by\n running the batch in a single blocking call \u2014 which freezes AE\'s interface for\n the duration and reports no progress. Read `undoSteps` before you tell anyone\n how to undo the work.\n- **`transactional: true` never rolled anything back.** It fired one menu-command\n Undo, and menu commands silently do nothing over this bridge \u2014 and one Undo is\n one step, not a batch. It stops at the first failure, as it always really did,\n and now says `rolledBack: false` and names the stop point. The ops before the\n failure stay applied. Use `diff: true` to see what landed.\n- **`reorder_layer` had never worked at all.** Every call it ever served threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. It now takes exactly one of `beforeLayerId`, `afterLayerId` or\n `toIndex`, prefers the id forms because this is the op that invalidates\n indexes, and `toIndex` means the index the layer **ends up at**.\n- **`downsample: 1` was not full resolution.** It skipped setting the comp\'s\n resolution at all, so it inherited the viewer\'s Resolution dropdown \u2014 on a comp\n a designer had left at Quarter, `downsample: 1` returned a quarter-size frame\n and `downsample: 2` returned one four times larger. The response was honest\n about the dimensions; the picture was not the one asked for. Factor 1 is now\n set explicitly like any other and restored afterwards.\n\n### Two calls changed under you\n\n- **`create_shape_layer` now spawns at `[0,0]`**, with the anchor at `[0,0]`, so\n the layer\'s coordinate space *is* the comp\'s and every vertex, rect position\n and path you add afterwards is in comp pixels. After Effects\' own spawn point\n is the comp centre, which silently offsets a drawing authored in comp\n coordinates by half a frame \u2014 easy to miss on a downsampled screenshot.\n `position: "center"` restores AE\'s behaviour, and any `[x,y]` places the origin\n yourself. The result echoes what it ended up with.\n- **`get_house_style` returns a digest, not the document.** A few hundred tokens:\n palette as named hexes, type, motion, layout, and a note naming anything it\n could not summarise. Pass `detail: "full"` for the whole thing \u2014 you need it\n before `set_house_style`, which replaces the file rather than patching it. An\n unstructured guide comes back `structured: false` with its opening text, which\n is an honest "I could not read this as a spec" rather than an empty answer.\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp`.** Fingerprint a comp before a write, then\n ask what moved: layers added, removed, renamed, retimed, re-parented, keyframe\n counts, expressions and effects gained or lost \u2014 and a count of the layers that\n did not move. Tens of tokens where two full reads were thousands. It does\n **not** record property values, expression text, effect parameters, masks or\n shape contents, so `changeCount: 0` means none of the recorded fields moved,\n not that the comp is identical.\n- **`diff: true` on `run_jsx` and `run_batch`** does the same inside the call, so\n there is no window between the write and the fingerprint \u2014 and on a failure the\n diff rides on the error, which is how you find where a half-applied script\n stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact sheet**,\n each tile labelled with its own time, held to roughly the pixel budget of a\n single frame. Judging motion is one call now, not three: one image in your\n context instead of three, and one render request instead of three back-to-back\n ones, which is the pattern that provoked stale frames. `time` and `times` are\n mutually exclusive; there is no `times` on `screenshot_layer`.\n- **Three distinct failures, with opposite remedies.** `Stale frame` (AE served\n an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is not a\n whole PNG; not a timeout \u2014 retry at downsample 6\u20138 or shoot the precomps\n separately), `Render timed out` (still rendering \u2014 wait before retrying, or a\n retry queues behind it). A frame that is genuinely all-transparent is still\n `empty: true` with no image. On a sheet, one bad tile is drawn as a marked\n block and named in `warning`; the rest of the sheet is good.\n\n### run_jsx\n\n- **A failure names the line of *your* script and prints its text**, and says so\n honestly when the number cannot be mapped rather than guessing. This one was\n claimed once before it was true: until the live pass every error said line 1\n and printed line 1\'s text, with the real number only in the parenthetical after\n it, because AE reports `Error.start`/`Error.end` as `0` on every error rather\n than as the character offsets its documentation describes.\n- **`diff: true` on `run_jsx` now reaches After Effects.** The server was\n building a fresh argument object and dropping every field it did not enumerate,\n so the flag was discarded before the call left. The same flag on `run_batch`\n always worked, which is what hid it.\n- **`scriptPath` and `libraries`** keep a long script and its shared helpers out\n of the conversation entirely. Libraries are inlined ahead of the script in the\n same scope, so their functions are callable from it; they are re-evaluated on\n every call, so keep them to declarations rather than to work.\n- **Helpers in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using the\n same code `set_temporal_ease` uses, not a copy), `shape` (which lands at\n `[0,0]`), and `withoutUndoGroup`. The whole `OPS` table is callable too.\n\n### New tools, and one thing you no longer have to get right\n\n- **`duplicate_comp`** returns the new comp id, so you never look for the copy by\n name. Shallow by default like AE\'s own Duplicate \u2014 the copy\'s precomp layers\n point at the *same* nested comps \u2014 with `deep: true` for a real variant.\n- **`place_audio_cues`** scores a scene in one call: a cue list becomes one audio\n layer each, imported once per file, named, trimmed, levelled in dB, in a single\n undo step, all-or-nothing, with a `dryRun`.\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.** Pass\n one `{influence, speed}` pair per side; the count that worked comes back as\n `easeDimensions`. The bare `parameter 2` failure is no longer yours to avoid.\n- **`export_mogrt` refuses an empty Essential Graphics panel up front**, naming\n the controller count. It used to attempt the export and then blame a modal\n dialog, which under the default dialog suppression is the one cause ruled out\n by construction. When an export does fail now, the message says what was\n checked and \u2014 if dialogs were suppressed \u2014 that the cause is genuinely unknown,\n because AE answers with a bare boolean and has no way to say why.\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two writes\n issued together run in the order you issued them and a long `run_batch` holds\n the lock until its last chunk lands \u2014 nothing else drops into the middle of\n work the user asked for as one thing. A call that waited says `queuedBehind` and `waitedMs`. Reads are\n never queued. There is a new diagnosis to tell apart from a bridge timeout: a\n call *dropped while waiting for the write queue* never reached After Effects,\n so re-sending it is safe \u2014 which is the opposite of what a timeout means.\n- **The issue journal has two scopes.** `project` is this project\'s notes; `user`\n travels with the person across every project, so log tool and After Effects\n behaviour there. Ids are unique only within a journal, so open an entry with\n the qualified form the listing shows (`user:\u2026`).\n- **The guidance moved.** What the server sends every session is now a short\n pointer rather than a summary, because it was resident in every request the\n user ever made. The narrative is `ae_guide({topic: "after-effects"})`, with\n `extendscript-gotchas` behind it for raw `run_jsx` and this topic for changes.\n In Claude Code and claude.ai the same text is the `after-effects` skill and the\n files in its `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the main guide: opacity does not propagate\n through parenting, and a camera null is parented to while it is still at\n identity.\n\n## 0.3.1\n\n- **Shape reads got much cheaper.** `get_layer_full` no longer returns\n `ADBE Vector Materials Group` \u2014 the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It\n was around three quarters of the bytes of a shape read: one 68px circle cost\n 4,400 tokens, of which the geometry was about 40. `materialsOmitted` counts\n what was dropped; `shapeMaterials: true` brings it back for a genuinely\n extruded shape. A group Transform still at its creation values collapses to\n `atDefaults: true` the same way.\n- **`shapeDetail: "compact"`** returns one indented line per group instead of\n the JSON tree \u2014 every name the write tools address a node by, with `[3 keys]`\n or `[expr]` marking the animated properties. Measured end to end on one real\n layer: 13,369 characters before this release, 3,052 once the materials block\n went, 643 compact. Reach for `"full"` only when you need exact values or\n keyframe indices.\n- **`run_jsx` never answers a bare `null`.** A script whose last statement is a\n bare expression completes and returns nothing \u2014 `"ping";` does not return\n `"ping"` \u2014 and that used to be indistinguishable on the wire from a script\n that never ran. It now comes back as\n `{ok: true, returned: null, undoGroup, note}`, which means *it ran to\n completion*. Do not re-run it: nothing rolls back, so a second run of a script\n that duplicated a layer or wrote keyframes does all of it twice. A returned\n value still comes back bare, falsy ones included.\n\n## 0.3.0\n\n- **Bounded reads.** `list_comps`, `list_layers` and `get_layer_full` take\n `include` to name the sections you want, plus `maxKeyframes` and `shapeDepth`\n on the deep read. Omit them all and you get everything, exactly as before.\n Anything left out is named and counted in the response (`included`,\n `keyframesOmitted`, `childrenOmitted`), so a short answer never passes for a\n complete one. This matters more than it sounds: a tool result is re-sent to\n you on every later request for the rest of the session, so one unbounded read\n is paid for many times.\n- **Screenshots downsample themselves.** Omit `downsample` and it is derived\n from the comp \u2014 2 at 1080p, 3 at 4K, aiming at a long edge near 1280px. The\n result reports the size actually returned. Pass `downsample: 1` only when you\n genuinely need full resolution.\n- **A stale frame is now an error, not a picture.** AE sometimes re-serves a\n frame it rendered for an unrelated request, at a different time and even at a\n different downsample factor, with nothing in the response to say so. Those are\n refused with `Stale frame` naming the request whose pixels came back. Wait a\n few seconds and retry at a higher `downsample`.\n- **A fully transparent frame comes back as `{empty: true, reason}`** with no\n image. That is a fact about the composition \u2014 wrong time, layer outside its\n in/out points, disabled, zero opacity \u2014 not something to retry.\n- **Footage import**, with `import_footage` and `create_footage_layer`. The\n import refuses an SVG whose `viewBox` asks for one aspect ratio and imports at\n another: that is a real AE bug which renders as nothing at all, with no error\n at any stage.\n- **`export_mogrt`**, which suppresses the modal dialogs that otherwise freeze\n this whole connection until someone clicks them in After Effects. Never drive\n `exportAsMotionGraphicsTemplate` from `run_jsx`.\n- **Text alignment is justification**, not a measured anchor offset, so it stays\n correct when the text changes later. Tracking is set explicitly rather than\n inherited from the user\'s Character panel.\n\n## Older, but you will meet it first\n\n**The panel is versioned separately from the tools, and it does not update\nitself.** It loads at After Effects launch and only at launch, so after any\nupgrade the tools can be newer than the code answering them. When that happens a\ncall returns a remediation message rather than a confusing `Unknown op` \u2014 relay\nit. The distinction worth reading carefully: if it says the panel is *installed*\nbut the running one is older, running `setup_panel` again changes nothing and\nonly quitting and reopening After Effects will.'
3336
+ description: "What changed in the After Effects tools, one section per release, newest first \u2014 each entry leads with the rule as it now stands and quotes the old rule it replaces, so a project's own notes can be found and corrected. Read it when a call behaves differently from what you expected, when the user says a tool used to do something else, or with `since` to see only the releases after the one this project last absorbed.",
3337
+ body: "<!--\nFormat contract. `ae_guide({topic: \"whats-new\", since: \"<version>\"})` filters\nthis file by machine and the absorb-release prompt greps project docs with it:\n\n- One `## <semver>` section per release, newest first. No other `##` heading\n below the first release heading; group inside a release with `###`.\n- Everything above the first `## <semver>` is the preamble. It is returned with\n every filtered read, so it stays short.\n- An entry is one bullet. It opens with a **bold phrase stating the rule as it\n now stands**, present tense, then the reason and what changed.\n- A bullet whose rule replaces something a project's own docs may say carries\n one or more indented `supersedes:` lines directly under it, each quoting the\n OLD rule in the words a project doc would have used. They are grep targets:\n short, literal, no markdown.\n-->\n\n# What changed\n\nRead this when something behaves differently from what you expected, or when the\nuser tells you a tool used to work another way. It is the release notes an agent\nneeds rather than the ones a human reads: one section per release, newest first.\nEvery entry leads with the rule as it now stands; a `supersedes:` line under it\nquotes the old rule in the words a project's own notes would have used, so those\nnotes can be searched for and corrected. Pass `since` \u2014 as in\n`ae_guide({topic: \"whats-new\", since: \"0.4.0\"})` \u2014 to get only the releases\nafter that version; the absorb-release prompt is the flow that applies them to\na project once and records the version so the history is not read again.\n\n**When this topic and a tool's own schema disagree, believe the schema.** It is\nrefreshed at each release and a build in between can be ahead of it.\n\n**The panel is versioned separately from the tools, and it does not update\nitself.** It loads at After Effects launch and only at launch, so after any\nupgrade the tools can be newer than the code answering them. When that happens a\ncall returns a remediation message rather than a confusing `Unknown op` \u2014 relay\nit. The distinction worth reading carefully: if it says the panel is *installed*\nbut the running one is older, running `setup_panel` again changes nothing and\nonly quitting and reopening After Effects will.\n\n## 0.5.0\n\nThe release where the guidance split into a core and references, the journal\nstarted coming to you instead of you going to it, and three more things After\nEffects does silently \u2014 a batch's progress no client ever saw, a broken\nexpression reported as ok, a justification that did not land \u2014 became\nvisible. Two results changed shape; those are first, because a caller reading\nthe old shape gets nothing rather than an error.\n\n### Two results changed shape\n\n- **`find_layers` returns `{matches, count, compsSearched, included}`, and a\n match is id, index, name, sourceType, compId and compName unless `include`\n asks for more.** It used to answer a bare array of full layer summaries \u2014\n flags, timing, parent, on every match \u2014 with no way to bound it, so nine name\n matches cost about two thousand tokens, re-sent on every later request.\n `include` takes the `list_layers` section names (`flags`, `timing`,\n `parent`); unlike `list_layers`, omitting it means the bounded record, not\n everything, and the result echoes `included`. A caller that read the old\n array reads `matches` now.\n supersedes: find_layers cannot be bounded\n supersedes: find_layers returns an array of layer summaries\n- **`place_audio_cues` results carry one small entry per cue \u2014 `layerId`,\n `name`, `time`, plus what an option changed \u2014 and `dryRun` answers counts,\n `wouldImport`, `wouldReuse` and the failing cues only.** The per-cue\n `levelDb`, `label`, `index` and `itemId` were echoes of the request, and a\n seven-cue dry run that found nothing wrong cost about 1,500 tokens of them.\n Read a level off the layer, not out of the result.\n supersedes: place_audio_cues echoes the levelDb written for every cue\n supersedes: dryRun on place_audio_cues returns the resolved cue list\n\n### Three silences, now reported\n\n- **`run_batch` over 500 ops returns its `jobId` before the first chunk runs,\n and progress is delivered on `await_job` \u2014 send that call with a progress\n token and `notifications/progress` arrive while it waits.** Nothing can ride\n on the `run_batch` call itself: its response is already back, and a\n notification sent on a request's token after its response is one every\n spec-compliant client has already stopped listening for \u2014 which is where\n every progress message for a long batch went before 0.5.0, seen by no real\n client. `get_job` polls the same state without a token.\n supersedes: run_batch progress arrives on the run_batch call\n supersedes: pass a progressToken on run_batch to see its progress\n- **`set_expression` throws when After Effects cannot evaluate the expression,\n and `get_expression` returns `expressionError`.** Assigning an expression\n succeeds whatever the text says, and AE's only report is a warning banner in\n its interface; the tool now evaluates the property after the write and fails\n with AE's message and the property path. The expression stays written \u2014 fix\n it and call again, or `clear_expression`. `toggle_expression({enabled:\n true})` verifies the same way. A non-empty `expressionError` means the\n property is not being driven by that expression, whatever `enabled` says.\n supersedes: set_expression reports success for a broken expression\n supersedes: ask the user to check the property for an expression warning banner\n- **`set_text` and `create_text_layer` read the justification back after every\n write, re-assert it once if it moved, and throw naming expected and actual\n if it still disagrees.** On 26.3 a `set_text` that changed only `text` has\n re-centred a left-aligned layer. Both results carry `justification` as a\n name and `justificationReasserted`; a failing `create_text_layer` removes\n the layer it made, so nothing is left without an id. An unknown\n justification name is refused rather than ignored.\n supersedes: set_text leaves justification alone when you do not pass it\n supersedes: verify alignment by sourceRect after every set_text\n\n### Deleting a comp, and what it leaves behind\n\n- **`delete_comp` reports `unusedSolidsLeft`, and `purgeUnusedSolids: true`\n removes the comp's own now-unused solids in the same undo step.** A solid's\n source is a footage item in the Solids folder, and deleting the comp only\n ever removed the layer, so a session that built and discarded comps left\n hundreds behind. Only the deleted comp's own solids are considered; one\n another comp still uses is kept and reported in `keptSolids` with that comp.\n supersedes: delete_comp removes a comp and its solids\n supersedes: sweep the Solids folder by hand after deleting comps\n- **`purge_unused_footage` sweeps the project: solids only by default,\n `solidsOnly: false` for every unused footage item, `folderId` to scope it,\n `dryRun: true` to list `wouldRemove` without an undo step.** Never a comp or\n a folder \u2014 a nested comp nothing uses is not footage.\n supersedes: remove unused footage with run_jsx\n\n### Sound\n\n- **`place_audio_cues` takes `loop`, `fadeIn`/`fadeOut` and `stretch` per cue,\n and `fadeFloorDb` per call.** `loop: true` time-remaps with a wrap-around\n expression, keeps AE's two default keys and re-asserts the out point; fades\n keyframe Audio Levels at the layer's real in and out between the floor\n (default `-48`) and the cue's level, and a fade that does not fit is refused\n by cue index; `stretch` is a percentage with the start time and trims\n re-asserted after it. A stretched loop bakes the factor into its expression,\n so a later hand change to stretch leaves the loop at the old rate. All of it\n is validated before anything is created and rolled back with the rest.\n supersedes: loop a bed with run_jsx and timeRemap after place_audio_cues\n supersedes: fade audio in run_jsx after placing the cues\n\n### The journal comes to you\n\n- **A failed call ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\n for up to three entries matching its tool and error text, and the\n `list_known_issues({id})` call that opens the first.** Nobody reads the\n journal ahead of time any more; the pointer arrives with the failure, and a\n match moves the entry's `lastSeen`.\n supersedes: read list_known_issues before nontrivial work\n supersedes: check list_known_issues before guessing\n- **`log_issue` takes `errorText` and `kind`, and the same tool with the same\n error text extends the existing entry even under a new title** \u2014 the result\n says `mergedBy: \"errorText\"` and keeps the existing title. `kind:\n \"ae-quirk\"` marks a permanent After Effects behaviour; the default\n `tool-bug` is presumed fixed once last seen on an older server. Entries\n record `firstVersion` and `lastVersion`.\n supersedes: call list_known_issues first so you reuse the title when logging\n- **Entries archive themselves \u2014 unseen for 30 days, or a `tool-bug` last seen\n on an older server \u2014 and `archive_issue({id, reason})` retires one by\n hand.** Archived entries are hidden from the index and counted in\n `archivedCount`; `includeArchived: true` lists them, a read by `id` always\n works, and a fresh `log_issue` reopens one with `reopened: true`. Index\n lines carry `kind`, `lastSeen` and `lastVersion` and no summary sentence,\n sorted by tool match and then most recently seen.\n supersedes: the journal only grows, entries are never removed\n- **The report flow checks the tracker before drafting.** An entry a closed\n report already covers is archived with that URL; one an open report covers\n is marked reported. Bodies go through a file, and \"all of them\" is one\n approval.\n\n### Reading the history once\n\n- **`ae_guide({topic: \"whats-new\", since: \"<version>\"})` returns only the\n releases after that version, opening with the server's own version**, and\n this file has a shape a machine can filter: one section per release, every\n entry leading with the rule as it now stands, `supersedes:` lines under it\n quoting the old rule. The absorb-release prompt (`/absorb-release` where\n prompts are commands) applies the delta to a project's notes once and\n records the version in the `Tools version last absorbed:` line\n `init_project` writes into AGENTS.md.\n supersedes: read the whole whats-new guide after every update\n\n### The guidance\n\n- **The `after-effects` topic is a core of what silently produces wrong output\n on any task, and each subject is its own topic** \u2014 `animation`, `shapes`,\n `text`, `assembly`, `extendscript-gotchas`, `sound`, `mogrt-and-footage`,\n `issue-journal`, `whats-new` \u2014 twelve `ae_guide` topics in all with\n `style-guide` and `ae-setup`. In Claude Code and claude.ai the references\n are the files in the `after-effects` skill's `references/` folder, opened\n only when the core points at them. Keyframes, easing and rigging are in\n `animation`; raw scripting, with the ease-arity table, in\n `extendscript-gotchas` and nowhere else.\n supersedes: the after-effects skill covers keyframes text and shapes in one file\n supersedes: the ease arity table is in the main guide\n- **Four more After Effects facts, measured on 26.3, in `extendscript-gotchas`:**\n removing the last Time Remap key hides the property, so edit AE's two\n default keys in place or put an expression over them, and re-assert\n `outPoint` after enabling remapping; `TextDocument.justification` written\n from a script lands as RIGHT when handed CENTER, so set it through\n `set_text`; `OutputModule.setSettings` rejects Format, Channels, Depth and\n Color as read-only, so build an output template in the UI and apply it by\n name; and the ease arity is not derivable from a value's dimension, so\n never size a `KeyframeEase` array by hand.\n supersedes: clear the Time Remap keys before adding your own\n supersedes: set justification on the TextDocument from run_jsx\n supersedes: set the output module codec with setSettings from run_jsx\n- **The references carry the craft rules a project would otherwise keep in\n its own notes** (issue #103). `extendscript-gotchas`: a `.value` read at\n the playhead, a `setValue` that throws with the value applied on a comp not\n in a viewer, effects addressed by a name that is not unique and references\n that go stale when one is added, a track matte that switches its matte's\n video off, `replaceSource` keeping the old timing, `item.typeName`, Time\n Remap keys added before the defaults are removed. `animation`: a held pose\n as a hold key, a waypoint that must not stop, parenting at rest and before\n the pop with the parent-space arithmetic, a mirrored parent mirroring its\n text, expression text built from numbers, a shake as an expression over\n keys. `assembly`: a precomp clamped at its source, reversing with time\n remap, controls shared across every instance, a resized comp shifting what\n nests it, collapse transformations and motion blur across a nesting,\n replacing a nested comp, and verifying an assembly, a small detail, a\n doubtful tile and a cut. `sound`: peak for a one-shot and RMS for a bed\n with the onset on the beat, a trim clamped at the file's end, a bed that\n follows a move, a float WAV that will not place. `shapes`: one fill per\n group, the layer stack built back-to-front, a group's pivot, seams between\n shapes, centring a repeated run.\n\n### Connecting, and staying connected\n\n- **The server follows the panel's port.** A call refused on the port the\n server remembered makes it look again \u2014 7777 first, then the port file \u2014\n switch to whichever answers as the panel, and re-send the call once. Only a\n refused connection triggers this; a timed-out call reached After Effects and\n is never re-sent (issue #92). `AE_MCP_PORT` is a hard pin: with it set, no\n other port is ever tried.\n supersedes: reconnect the MCP server when ops fail on a port check_setup does not report\n- **`check_setup` reports the port that actually answers**, flags a stale\n port file, and carries a new `portAgreement` check. When calls go to one\n port and the panel answers on another, its advice is to retry or reconnect\n the MCP server. It never tells you to restart After Effects for that.\n supersedes: cannot reach the panel means nothing is listening so restart After Effects\n- **The panel waits instead of moving when an older copy of itself holds\n 7777.** It says so in its own window, names the holder, and takes the port\n over the moment that copy exits, with no restart. Two After Effects\n instances on one machine opt back into walking with `\"allowPortWalk\": true`\n in `~/.engineroom-ae-mcp/config.json`; the same file's `\"port\"` moves the\n panel. The port file only ever names a port that was really bound and is\n removed when the panel unloads.\n supersedes: the panel walks up to the next free port when 7777 is taken\n- **A panel that never loads on Windows gets a diagnosis, not a restart\n loop.** `check_setup` reads CEP's own log and, when CEP refused the panel's\n signature \u2014 an Adobe CEP 12 bug that ignores PlayerDebugMode \u2014 reports\n `panelSignature` with the steps to self-sign the installed panel with\n Adobe's ZXPSignCmd (issue #91). With no log yet, it gives the one command\n that turns CEP logging on. `setup_panel` now turns that logging on itself\n where it was never set, warns when it is about to replace a self-signed\n panel (`signedInstallOverwritten`, since the fresh copy needs signing\n again), and reports a signed panel as up to date rather than partial.\n supersedes: check_setup all green but the panel never loads means restart After Effects again\n- **`check_setup` under an `AE_MCP_PORT` pin still asks 7777 and the port\n file, and a pinned wrong port is reported as the pin, not as a dead panel.**\n The pin decides where ops go and is never walked past; the diagnosis says so,\n names the port the panel is really answering on, and tells you to change or\n unset `AE_MCP_PORT` and reconnect the MCP server \u2014 never to restart After\n Effects. A refused op under a pin says the same in its own message.\n\n### Found by the live pass\n\n- **`init_project` refuses the home directory and the filesystem root however\n they were arrived at, an explicit `dir` included.** Before this only the\n working-directory fallback was guarded, so `dir: \"~\"` resolved by a client,\n or `dir: \"/\"`, scaffolded `AGENTS.md` and a `renders/` folder into a home\n directory and reported success.\n- **`set_house_style` writes `house-style.md` with Unix line endings.**\n ExtendScript's default on macOS turned every line break into a bare carriage\n return: the tools read the file back correctly, so the round trip looked\n fine, but a designer opening the file in an editor saw one long line.\n\n## 0.4.0\n\nThe release where verifying a change stopped meaning reading the whole thing\nback \u2014 and where a live pass against After Effects 2026 found four things this\nserver had been claiming that were never true. Those are first, because an agent\nthat learned the old story will otherwise repeat it to a user.\n\n### Four things that were never true, and are now fixed\n\n- **`run_batch` is one undo step up to 500 ops; over 500 it is one step per\n chunk of 25, and `singleUndo: true` makes it one step up to 2000.** The\n one-step guarantee it shipped with before 0.4.0 did not exist on either path:\n After Effects discards an undo group opened in one script call and closed in\n another, so a 600-op batch landed as about six hundred separate steps while\n reporting one. Now up to 500 ops is genuinely one step; over 500 is one step\n per chunk of 25 \u2014 around 24 for 600 ops \u2014 with the measured count in\n `undoSteps` and a `note` saying it in words. `singleUndo: true` runs the batch\n in a single blocking call, which freezes AE's interface for the duration and\n reports no progress. Read `undoSteps` before you tell anyone how to undo the\n work.\n supersedes: run_batch is one undo step\n supersedes: a long run_batch is one undo step\n- **`transactional: true` stops at the first failure, rolls nothing back, and\n says so: `rolledBack: false` with the stop point named.** It never rolled\n anything back \u2014 it fired one menu-command Undo, and menu commands silently do\n nothing over this bridge; one Undo is one step, not a batch, in any case. The\n ops before the failure stay applied. Use `diff: true` to see what landed.\n supersedes: transactional: true rolls the batch back on failure\n- **`reorder_layer` works, and takes exactly one of `beforeLayerId`,\n `afterLayerId` or `toIndex`.** Every call it served before 0.4.0 threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. Prefer the id forms, because this is the op that invalidates indexes;\n `toIndex` means the index the layer **ends up at**.\n supersedes: reorder_layer is broken, use run_jsx\n supersedes: reorder_layer throws parent is not an INDEXED_GROUP\n- **`downsample: 1` is full resolution, whatever the viewer's Resolution\n dropdown is set to.** Before 0.4.0 factor 1 skipped setting the comp's\n resolution at all, so it inherited the viewer's setting \u2014 on a comp a designer\n had left at Quarter, `downsample: 1` returned a quarter-size frame and\n `downsample: 2` returned one four times larger. The response was honest about\n the dimensions; the picture was not the one asked for. Factor 1 is now set\n explicitly like any other and restored afterwards.\n supersedes: downsample: 1 means the viewer's resolution\n supersedes: set the viewer to Full before a downsample: 1 screenshot\n\n### Two calls changed under you\n\n- **`create_shape_layer` spawns at `[0,0]`**, with the anchor at `[0,0]`, so the\n layer's coordinate space *is* the comp's and every vertex, rect position and\n path you add afterwards is in comp pixels. After Effects' own spawn point is\n the comp centre, which silently offsets a drawing authored in comp coordinates\n by half a frame \u2014 easy to miss on a downsampled screenshot. `position:\n \"center\"` restores AE's behaviour, and any `[x,y]` places the origin yourself.\n The result echoes what it ended up with.\n supersedes: a new shape layer spawns at the comp centre\n supersedes: shape paths are offset by half a frame, subtract the comp centre\n- **`get_house_style` returns a digest, not the document; `detail: \"full\"`\n returns the document.** The digest is a few hundred tokens: palette as named\n hexes, type, motion, layout, and a note naming anything it could not summarise.\n You need the full document before `set_house_style`, which replaces the file\n rather than patching it. An unstructured guide comes back `structured: false`\n with its opening text, which is an honest \"I could not read this as a spec\"\n rather than an empty answer.\n supersedes: do not call get_house_style, read the digest in these notes instead\n supersedes: get_house_style returns the whole style guide\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp` fingerprint a comp and report what moved.**\n Fingerprint before a write, then ask: layers added, removed, renamed,\n retimed, re-parented, keyframe counts, expressions and effects gained or lost\n \u2014 and a count of the layers that did not move. Tens of tokens where two full\n reads were thousands. It does **not** record property values, expression\n text, effect parameters, masks or shape contents, so `changeCount: 0` means\n none of the recorded fields moved, not that the comp is identical.\n supersedes: verify a write by reading the comp back with list_layers and get_layer_full\n- **`diff: true` on `run_jsx` and `run_batch` does the same inside the call**,\n so there is no window between the write and the fingerprint \u2014 and on a\n failure the diff rides on the error, which is how you find where a\n half-applied script stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact\n sheet**, each tile labelled with its own time, held to roughly the pixel\n budget of a single frame. Judging motion is one call now, not three: one\n image in your context instead of three, and one render request instead of\n three back-to-back ones, which is the pattern that provoked stale frames.\n `time` and `times` are mutually exclusive; there is no `times` on\n `screenshot_layer`.\n supersedes: screenshot each time separately to judge motion\n- **Three screenshot failures, each with its own remedy.** `Stale frame` (AE\n served an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is\n not a whole PNG; not a timeout \u2014 retry at downsample 6\u20138 or shoot the precomps\n separately), `Render timed out` (still rendering \u2014 wait before retrying, or a\n retry queues behind it). A frame that is genuinely all-transparent is still\n `empty: true` with no image. On a sheet, one bad tile is drawn as a marked\n block and named in `warning`; the rest of the sheet is good.\n supersedes: truncated PNG from screenshot_frame means retry it\n\n### run_jsx\n\n- **A `run_jsx` failure names the line of *your* script and prints its text**,\n and says so honestly when the number cannot be mapped rather than guessing.\n This was claimed once before it was true: until the 0.4.0 live pass every\n error said line 1 and printed line 1's text, with the real number only in the\n parenthetical after it, because AE reports `Error.start`/`Error.end` as `0`\n on every error rather than as the character offsets its documentation\n describes.\n supersedes: run_jsx line numbers are wrong, count from the wrapper\n supersedes: run_jsx reports line 1 for every error\n- **`diff: true` on `run_jsx` reaches After Effects.** The server was building\n a fresh argument object and dropping every field it did not enumerate, so the\n flag was discarded before the call left. The same flag on `run_batch` always\n worked, which is what hid it.\n supersedes: diff: true on run_jsx does nothing\n- **`scriptPath` and `libraries` keep a long script and its shared helpers out\n of the conversation entirely.** Libraries are inlined ahead of the script in\n the same scope, so their functions are callable from it; they are\n re-evaluated on every call, so keep them to declarations rather than to work.\n- **Helpers are in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using\n the same code `set_temporal_ease` uses, not a copy), `shape` (which lands at\n `[0,0]`), and `withoutUndoGroup`. The whole `OPS` table is callable too.\n\n### New tools, and one thing you no longer have to get right\n\n- **`duplicate_comp` returns the new comp id**, so you never look for the copy\n by name. Shallow by default like AE's own Duplicate \u2014 the copy's precomp\n layers point at the *same* nested comps \u2014 with `deep: true` for a real\n variant.\n supersedes: duplicate a comp with run_jsx and find the copy by name\n- **`place_audio_cues` scores a scene in one call**: a cue list becomes one\n audio layer each, imported once per file, named, trimmed, levelled in dB, in a\n single undo step, all-or-nothing, with a `dryRun`.\n supersedes: place audio with run_jsx, one layer at a time\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.**\n Pass one `{influence, speed}` pair per side; the count that worked comes back\n as `easeDimensions`. The bare `parameter 2` failure is no longer yours to\n avoid.\n supersedes: set_temporal_ease fails with parameter 2, pass the right number of KeyframeEase objects by hand\n supersedes: set_temporal_ease fails on Scale, use run_jsx\n- **`export_mogrt` refuses an empty Essential Graphics panel up front**, naming\n the controller count. It used to attempt the export and then blame a modal\n dialog, which under the default dialog suppression is the one cause ruled out\n by construction. When an export does fail now, the message says what was\n checked and \u2014 if dialogs were suppressed \u2014 that the cause is genuinely\n unknown, because AE answers with a bare boolean and has no way to say why.\n supersedes: export_mogrt fails with a modal dialog when the comp has no Essential Graphics controllers\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two\n writes issued together run in the order you issued them and a long\n `run_batch` holds the lock until its last chunk lands \u2014 nothing else drops\n into the middle of work the user asked for as one thing. A call that waited\n says `queuedBehind` and `waitedMs`. Reads are never queued. There is a new\n diagnosis to tell apart from a bridge timeout: a call *dropped while waiting\n for the write queue* never reached After Effects, so re-sending it is safe \u2014\n which is the opposite of what a timeout means.\n supersedes: never issue two writes in the same turn, the order is not guaranteed\n- **The issue journal has two scopes.** `project` is this project's notes;\n `user` travels with the person across every project, so log tool and After\n Effects behaviour there. Ids are unique only within a journal, so open an\n entry with the qualified form the listing shows (`user:\u2026`).\n- **The guidance lives in `ae_guide` and the skill, not in what the server sends\n every session.** What the server sends is a short pointer rather than a\n summary, because it was resident in every request the user ever made. The\n narrative is `ae_guide({topic: \"after-effects\"})`, with `extendscript-gotchas`\n behind it for raw `run_jsx` and this topic for changes. In Claude Code and\n claude.ai the same text is the `after-effects` skill and the files in its\n `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the `animation` topic: opacity does not propagate\n through parenting, and a camera null is parented to while it is still at\n identity.\n\n## 0.3.1\n\n- **`get_layer_full` omits `ADBE Vector Materials Group` unless\n `shapeMaterials: true`.** That is the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It was around\n three quarters of the bytes of a shape read: one 68px circle cost 4,400\n tokens, of which the geometry was about 40. `materialsOmitted` counts what was\n dropped; `shapeMaterials: true` brings it back for a genuinely extruded shape.\n A group Transform still at its creation values collapses to `atDefaults:\n true` the same way.\n supersedes: shape reads are huge, avoid get_layer_full on shape layers\n- **`shapeDetail: \"compact\"` returns one indented line per group** instead of\n the JSON tree \u2014 every name the write tools address a node by, with `[3 keys]`\n or `[expr]` marking the animated properties. Measured end to end on one real\n layer: 13,369 characters before 0.3.1, 3,052 once the materials block went,\n 643 compact. Reach for `\"full\"` only when you need exact values or keyframe\n indices.\n- **`run_jsx` never answers a bare `null`.** A script whose last statement is a\n bare expression completes and returns nothing \u2014 `\"ping\";` does not return\n `\"ping\"` \u2014 and that used to be indistinguishable on the wire from a script\n that never ran. It now comes back as `{ok: true, returned: null, undoGroup,\n note}`, which means *it ran to completion*. Do not re-run it: nothing rolls\n back, so a second run of a script that duplicated a layer or wrote keyframes\n does all of it twice. A returned value still comes back bare, falsy ones\n included.\n supersedes: a null from run_jsx means the script did not run, run it again\n\n## 0.3.0\n\n- **`list_comps`, `list_layers` and `get_layer_full` take `include` to name the\n sections you want**, plus `maxKeyframes` and `shapeDepth` on the deep read.\n Omit them all and you get everything, exactly as before. Anything left out is\n named and counted in the response (`included`, `keyframesOmitted`,\n `childrenOmitted`), so a short answer never passes for a complete one. This\n matters more than it sounds: a tool result is re-sent to you on every later\n request for the rest of the session, so one unbounded read is paid for many\n times.\n supersedes: get_layer_full returns everything, there is no way to bound it\n- **Screenshots downsample themselves.** Omit `downsample` and it is derived\n from the comp \u2014 2 at 1080p, 3 at 4K, aiming at a long edge near 1280px. The\n result reports the size actually returned. Pass `downsample: 1` only when you\n genuinely need full resolution.\n supersedes: always pass downsample on screenshot_frame or it returns full resolution\n- **A stale frame is an error, not a picture.** AE sometimes re-serves a frame\n it rendered for an unrelated request, at a different time and even at a\n different downsample factor, with nothing in the response to say so. Those\n are refused with `Stale frame` naming the request whose pixels came back.\n Wait a few seconds and retry at a higher `downsample`.\n- **A fully transparent frame comes back as `{empty: true, reason}`** with no\n image. That is a fact about the composition \u2014 wrong time, layer outside its\n in/out points, disabled, zero opacity \u2014 not something to retry.\n- **`import_footage` and `create_footage_layer` bring footage in**, and the\n import refuses an SVG whose `viewBox` asks for one aspect ratio and imports at\n another: that is a real AE bug which renders as nothing at all, with no error\n at any stage.\n supersedes: import footage with run_jsx and importFile\n- **`export_mogrt` exports a Motion Graphics template and suppresses the modal\n dialogs** that otherwise freeze this whole connection until someone clicks\n them in After Effects. Never drive `exportAsMotionGraphicsTemplate` from\n `run_jsx`.\n supersedes: export a mogrt with run_jsx and exportAsMotionGraphicsTemplate\n- **Text alignment is justification**, not a measured anchor offset, so it\n stays correct when the text changes later. Tracking is set explicitly rather\n than inherited from the user's Character panel.\n supersedes: anchorAlign moves the anchor point, re-align text after every edit"
2728
3338
  }
2729
3339
  ];
2730
3340
  var PROMPTS = [
3341
+ {
3342
+ name: "absorb-release",
3343
+ description: "After the After Effects tools update \u2014 read what changed since this project last caught up, fix the notes in this folder the update made stale, retire journal entries whose fix has shipped, and record the version so the history is not read again",
3344
+ argumentHint: "[the version this project last absorbed, if its docs do not say]",
3345
+ body: '# Absorb a tools update\n\nThe After Effects tools have updated, or the user thinks they have. A release\nchanges what the tools do, and what earlier sessions wrote down about them in\nthis project \u2014 workarounds in AGENTS.md or CLAUDE.md, entries in the issue\njournal \u2014 does not update itself. This flow reads the delta once, corrects those\nnotes, and records the version so no later session reads the history again.\n\n`$ARGUMENTS` is the version the project last absorbed, if the user typed one.\n\n## When to run this\n\nAt the start of a session, compare two versions:\n\n- **Recorded**: the line `Tools version last absorbed: <version>` in the\n project\'s docs. `init_project` writes it into AGENTS.md.\n- **Running**: the server\'s own version. `list_known_issues` returns it as\n `serverVersion`, and every `ae_guide({topic: "whats-new"})` answer opens with\n it.\n\nRunning newer than recorded, or no recorded line at all, is the cue. Two things\nupdate separately, so there can be two versions to look at: the MCP server (via\nnpx, the standalone binary, or the Claude Desktop bundle) and, in Claude Code,\nthe plugin that carries the skills and this command, whose version is in its\n`.claude-plugin/plugin.json`. The **server\'s** version is the one this flow\nrecords, because the server is what answers tool calls and what serves the\nwhats-new topic. If the plugin is ahead of the server, the skill text may\ndescribe tools the server does not have yet \u2014 say so, and suggest updating the\nserver rather than the notes.\n\n## 1. Find the recorded version\n\nLook for `Tools version last absorbed:` in AGENTS.md, then CLAUDE.md,\n`.cursor/rules/`, `.windsurfrules`, `.github/copilot-instructions.md` and any\nnotes under `.ae-mcp/`. `$ARGUMENTS` overrides whatever is found.\n\nNothing recorded and nothing typed: ask whether these tools have been used in\nthis project before. If they have, treat it as never absorbed and read the whole\nhistory \u2014 one long read, once. If the project is new, there is nothing to\nabsorb: go to step 5 and just record the version.\n\n## 2. Read the delta\n\nCall `ae_guide({topic: "whats-new", since: "<recorded>"})`. The first line\nnames the server version \u2014 keep it for step 5. If it says nothing is newer, tell\nthe user they are up to date, make sure the recorded line exists (step 5), and\nstop.\n\nOtherwise, every bullet leads with the rule as it now stands, and a\n`supersedes:` line under it quotes the old rule in the words a project\'s notes\nwould have used. Those lines are what the next step searches for.\n\n## 3. Correct the project\'s own notes\n\nFor every `supersedes:` line, search the files from step 1 for the old rule \u2014\nthe phrase, and its key words, because a note will rarely match word for word.\n`house-style.md` is about the look, not the tools; leave it alone.\n\nFor each hit, show the user the note as it stands, the rule that replaces it,\nand the edit you propose: a rewrite when the note still says something true, a\ndeletion when it only described a bug that is now fixed. A note that mentions a\nworkaround also teaches the workaround, so prefer deleting to rephrasing. These\nare their notes \u2014 collect the proposed edits, get one yes, then make them.\nNever edit silently.\n\nNo hits: say so in one line and move on.\n\n## 4. Retire journal entries whose fix has shipped\n\nCall `list_known_issues({status: "reported"})`. A reported entry carries the URL\nof the issue that was filed. If `gh` is installed and signed in (`gh auth\nstatus`), check each one:\n\n```bash\ngh issue view <number> --repo <repo> --json state,stateReason\n```\n\n- `CLOSED` with `stateReason: COMPLETED` \u2014 the fix shipped. Call\n `archive_issue({id, reason: "<url> closed"})`, using the scope-qualified id\n the listing shows (`user:\u2026`, `project:\u2026`).\n- `CLOSED` with `NOT_PLANNED` \u2014 the behaviour is staying, so the workaround is\n still worth having. Leave it.\n- `OPEN` \u2014 leave it.\n\nNever archive an entry tagged `kind: "ae-quirk"` on the strength of a closed\nissue: those describe After Effects itself, not a tool bug, and a release does\nnot change them.\n\nThen glance at the unreported entries against the delta: an entry whose symptom\na bullet says is fixed can be archived with the release as the reason \u2014 propose\nit, do not assume.\n\nIf `gh` is missing or not signed in, do not install or configure it. List the\nreported entries with their URLs so the user can check them, and leave the\njournal as it is.\n\n## 5. Record the version\n\nWrite `Tools version last absorbed: <server version>` \u2014 the version from the\nfirst line of step 2 \u2014 replacing the existing line, or adding it under a\n`## Tool updates` heading in AGENTS.md when there was none. No AGENTS.md: use\nthe file the project reads (CLAUDE.md, or the rules file found in step 1). No\nproject docs at all \u2014 a Claude Desktop session with no folder, say \u2014 tell the\nuser there is nowhere to write it, suggest `init_project` for when they next\nwant a project folder, and stop.\n\nAfter this the history is not loaded again for this project until the next\nupdate.\n\n## 6. Say what happened\n\nOne short paragraph, in plain language: which version, how many notes were\ncorrected, which journal entries were retired. No file paths they did not ask\nabout, and no talk of "supersedes lines" \u2014 that is machinery.'
3346
+ },
2731
3347
  {
2732
3348
  name: "create-style-guide",
2733
3349
  description: "Capture or update the look of this project \u2014 palette, type, motion and layout \u2014 into the style guide that shapes everything built afterwards",
@@ -2744,7 +3360,7 @@ var PROMPTS = [
2744
3360
  name: "report-ae-issue",
2745
3361
  description: "Send a problem you hit with the After Effects tools to the people who maintain them",
2746
3362
  argumentHint: "[what went wrong, in your own words]",
2747
- body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns a one-line index\nof what earlier sessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion`\nand `platform`. Once they have chosen, read each chosen entry in full with\n`list_known_issues({id})` \u2014 the draft below needs the symptom and workaround\ntext, which the index does not carry.\n\n- **Entries exist** \u2014 show them as a short numbered list, one plain sentence each\n ("Text layers ended up in the wrong place when a font was missing"), not the\n raw titles. Ask which to send; offer "all of them" as an option.\n- **No entries, but `$ARGUMENTS` describes something** \u2014 work from that. Ask what\n they were trying to do and what happened instead, then `log_issue` it so it is\n recorded before you send it.\n- **Nothing either way** \u2014 say there is nothing recorded to send, and that you\n will write things down as you hit them from now on. Stop there.\n\n## 2. Draft it\n\nShort. A maintainer should understand the problem in fifteen seconds.\n\n**Title:** one line, concrete. `set_temporal_ease fails on Position with "Value\narray does not have 1 elements"` \u2014 not `Keyframe bug`.\n\n**Body:** four short sections, a couple of sentences each.\n\n```markdown\n**What happens**\n<the failing call and the exact error, or the wrong result>\n\n**Why** (if known)\n<one line \u2014 omit this section entirely if unknown>\n\n**Workaround**\n<what got past it>\n\n**Environment**\nafter-effects-mcp <serverVersion> \xB7 <platform> \xB7 After Effects 2026\n```\n\nInclude the failing call and error text verbatim \u2014 that is the part that makes it\nfixable. Leave out the user\'s own content: comp and layer names from their\nproject, file paths, client names, anything about the video they are making. If a\ndetail like that is load-bearing, replace it with a placeholder.\n\n## 3. Show it and get a yes\n\nShow the finished title and body and ask whether to send it. This posts publicly\nto a repository under their name if `gh` is authenticated, so it needs a real\nanswer, not an assumption. If they want to change the wording, change it.\n\n## 4. Send it\n\nTry `gh` first:\n\n```bash\ngh issue create --repo <repo> --title "<title>" --body "<body>"\n```\n\nIf `gh` is missing or not authenticated, do not try to install or configure it.\nBuild a prefilled link instead \u2014 URL-encode the title and body onto\n`<newIssueUrl>` as `?title=\u2026&body=\u2026` \u2014 and give it to them with one line of\ninstruction: open this, it will already be filled in, press the green button. A\nGitHub account is needed to press it; if they do not have one, say so plainly and\noffer to write the text out for them to send another way.\n\n## 5. Close the loop\n\nOn success, call `mark_issue_reported` with the entry `id` and the URL, so no\nlater session asks them to report the same thing twice. Use the same\nscope-qualified id the listing gave you (`user:\u2026`, `project:\u2026`): the two\njournals can hold the same slug, reporting one says nothing about the other, and\nan unqualified id leaves which one moved to chance. Then tell them where it went,\nin one sentence, with the link.\n\nIf they decline, leave the entry alone \u2014 it stays unreported and can be offered\nagain another day. Do not mark it.'
3363
+ body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns a one-line index\nof what earlier sessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion`\nand `platform`. Once they have chosen, read each chosen entry in full with\n`list_known_issues({id})` \u2014 the checks and the draft below need the symptom,\nerror text and workaround, which the index does not carry.\n\n- **Entries exist** \u2014 show them as a short numbered list, one plain sentence each\n ("Text layers ended up in the wrong place when a font was missing"), not the\n raw titles. Ask which to send; offer "all of them" as an option. Remember the\n answer: if they said "all of them", step 4 takes one approval for the lot, not\n one per entry.\n- **No entries, but `$ARGUMENTS` describes something** \u2014 work from that. Ask what\n they were trying to do and what happened instead, then `log_issue` it so it is\n recorded before you send it.\n- **Nothing either way** \u2014 say there is nothing recorded to send, and that you\n will write things down as you hit them from now on. Stop there.\n\n## 2. Check whether the maintainers already know\n\nBefore drafting anything, look at what has already been sent to them \u2014 sending\nthe same problem twice wastes their time and the user\'s. `repo` came back from\nstep 1.\n\n```bash\ngh issue list --repo <repo> --state all --limit 200 --json number,title,state,url\n```\n\nMatch each chosen entry against that list by the tool it names and by its error\ntext: a title that names the same tool and the same failure, or quotes the same\nerror, is the same problem however it is worded. When a title alone does not\nsettle it, read the body:\n\n```bash\ngh issue view <number> --repo <repo> --json title,body,state,url\n```\n\nFor every entry that matches something:\n\n- **The match is closed** \u2014 it has already been dealt with. Do not send it again.\n Call `archive_issue` with the entry\'s scope-qualified id and the existing link\n as the reason (`"already closed upstream: <url>"`), so it leaves the list and no\n later session offers it. Tell the user in one plain sentence that this one was\n already fixed or already handled, with the link; if the fix came in a newer\n version than `serverVersion`, say that updating is how to get it.\n- **The match is open** \u2014 it is known and still being looked at. Do not send a\n second copy. Call `mark_issue_reported` with the entry\'s scope-qualified id and\n the existing link, and tell the user it is already on the maintainers\' list,\n with the link. If they have something to add, it belongs on that one.\n\nOnly the entries with no match go on to step 3. If none are left, say so and\nstop.\n\nIf `gh` is missing or not signed in, skip this step, say so in one line, and\ncarry on \u2014 step 5 has the fallback for sending.\n\n## 3. Draft it\n\nShort. A maintainer should understand the problem in fifteen seconds.\n\n**Title:** one line, concrete. `set_temporal_ease fails on Position with "Value\narray does not have 1 elements"` \u2014 not `Keyframe bug`.\n\n**Body:** four short sections, a couple of sentences each.\n\n```markdown\n**What happens**\n<the failing call and the exact error, or the wrong result>\n\n**Why** (if known)\n<one line \u2014 omit this section entirely if unknown>\n\n**Workaround**\n<what got past it>\n\n**Environment**\nafter-effects-mcp <serverVersion> \xB7 <platform> \xB7 After Effects 2026\n```\n\nInclude the failing call and error text verbatim \u2014 that is the part that makes it\nfixable. Leave out the user\'s own content: comp and layer names from their\nproject, file paths, client names, anything about the video they are making. If a\ndetail like that is load-bearing, replace it with a placeholder.\n\n## 4. Show it and get a yes\n\nShow the finished title and body and ask whether to send it. This posts publicly\nto a repository under their name if `gh` is authenticated, so it needs a real\nanswer, not an assumption. If they want to change the wording, change it.\n\nIf they chose "all of them" in step 1, show every draft together, once, and take\none yes for the lot. Do not ask again for each one.\n\n## 5. Send it\n\nTry `gh` first. Write the body to a temporary file and pass the file \u2014 a\nmulti-line markdown body with backticks and quotes does not survive being quoted\non a command line:\n\n```bash\ngh issue create --repo <repo> --title "<title>" --body-file <temp-file>\n```\n\nOne call per draft; remove the temporary file afterwards.\n\nIf `gh` is missing or not authenticated, do not try to install or configure it.\nBuild a prefilled link instead \u2014 URL-encode the title and body onto\n`<newIssueUrl>` as `?title=\u2026&body=\u2026` \u2014 and give it to them with one line of\ninstruction: open this, it will already be filled in, press the green button. A\nGitHub account is needed to press it; if they do not have one, say so plainly and\noffer to write the text out for them to send another way.\n\n## 6. Close the loop\n\nOn success, call `mark_issue_reported` with the entry `id` and the URL, so no\nlater session asks them to report the same thing twice. Use the same\nscope-qualified id the listing gave you (`user:\u2026`, `project:\u2026`): the two\njournals can hold the same slug, reporting one says nothing about the other, and\nan unqualified id leaves which one moved to chance. Then tell them where it went,\nin one sentence, with the link.\n\nIf they decline, leave the entry alone \u2014 it stays unreported and can be offered\nagain another day. Do not mark it.'
2748
3364
  }
2749
3365
  ];
2750
3366
  var GUIDE_NAMES = GUIDES.map((g) => g.name);
@@ -2754,35 +3370,38 @@ function getGuide(name) {
2754
3370
  function getPrompt(name) {
2755
3371
  return PROMPTS.find((p) => p.name === name);
2756
3372
  }
2757
- var SERVER_INSTRUCTIONS = 'You are driving a live After Effects session through this server. The user sees\nevery change as it happens and every call is a real undo step in their project.\n\nRead the guidance before you build. `ae_guide({topic: "after-effects"})` covers\norienting in a project, keyframes and easing, expressions, effects, text, shapes,\nand the traps that silently produce wrong output. In Claude Code and claude.ai\nthe `after-effects` skill is the same text \u2014 load one carrier, not both.\nTopics: ae-setup, after-effects, extendscript-gotchas, style-guide, whats-new.\n\nThree habits that matter before that call returns:\n\n- Identify by `id`, never by `index` \u2014 a layer\'s index shifts on every insert.\n- Bound your reads (`include`, `shapeDetail: "compact"`) and treat screenshots as\n one-off diagnostics rather than a feedback loop. Every tool result is re-sent\n to you on every later request in the session.\n- If a tool reports it cannot reach After Effects, call `check_setup` and relay\n its `nextSteps` verbatim; do not diagnose CEP by hand.';
3373
+ var SERVER_INSTRUCTIONS = 'You are driving a live After Effects session through this server. The user sees\nevery change as it happens and every call is a real undo step in their project.\n\nRead the guidance before you build. `ae_guide({topic: "after-effects"})` is the\ncore \u2014 orienting, bounded reads, verifying a write, screenshots, the bridge\nfailures \u2014 and points at one topic per subject (shapes, text, animation, raw\nscripting, sound, \u2026). In Claude Code and claude.ai the `after-effects` skill is\nthe same text \u2014 load one carrier, not both.\nTopics: ae-setup, after-effects, animation, assembly, extendscript-gotchas, issue-journal, mogrt-and-footage, shapes, sound, style-guide, text, whats-new.\n\nThree habits that matter before that call returns:\n\n- Identify by `id`, never by `index` \u2014 a layer\'s index shifts on every insert.\n- Bound your reads (`include`, `shapeDetail: "compact"`) and treat screenshots as\n one-off diagnostics rather than a feedback loop. Every tool result is re-sent\n to you on every later request in the session.\n- If a tool reports it cannot reach After Effects, call `check_setup` and relay\n its `nextSteps` verbatim; do not diagnose CEP by hand.';
2758
3374
 
2759
3375
  // src/issues/journal.ts
2760
- import fs8 from "node:fs";
2761
- import os4 from "node:os";
2762
- import path9 from "node:path";
3376
+ import fs9 from "node:fs";
3377
+ import os5 from "node:os";
3378
+ import path10 from "node:path";
2763
3379
  var REPO = "Engine-Room-Games/after-effects-mcp";
2764
3380
  var NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
3381
+ var ARCHIVE_AFTER_DAYS = 30;
2765
3382
  var SECTION_SYMPTOM = "What went wrong";
2766
3383
  var SECTION_CAUSE = "Why";
2767
3384
  var SECTION_WORKAROUND = "What worked";
3385
+ var ERROR_TEXT_CHARS = 400;
3386
+ var ISSUE_KINDS = ["tool-bug", "ae-quirk"];
2768
3387
  function journalRoot() {
2769
3388
  const override = journalOverride();
2770
3389
  if (override) return { dir: override, scope: "project" };
2771
3390
  const cwd = process.cwd();
2772
- const unusable = cwd === path9.parse(cwd).root || cwd === os4.homedir();
3391
+ const unusable = cwd === path10.parse(cwd).root || cwd === os5.homedir();
2773
3392
  if (!unusable) {
2774
3393
  try {
2775
- fs8.accessSync(cwd, fs8.constants.W_OK);
2776
- return { dir: path9.join(cwd, ".ae-mcp"), scope: "project" };
3394
+ fs9.accessSync(cwd, fs9.constants.W_OK);
3395
+ return { dir: path10.join(cwd, ".ae-mcp"), scope: "project" };
2777
3396
  } catch {
2778
3397
  }
2779
3398
  }
2780
- return { dir: path9.join(os4.homedir(), ".after-effects-mcp"), scope: "home" };
3399
+ return { dir: path10.join(os5.homedir(), ".after-effects-mcp"), scope: "home" };
2781
3400
  }
2782
3401
  function userJournalRoot() {
2783
3402
  const override = journalOverride();
2784
- if (override) return { dir: path9.join(override, "user"), scope: "user" };
2785
- return { dir: path9.join(os4.homedir(), ".ae-mcp"), scope: "user" };
3403
+ if (override) return { dir: path10.join(override, "user"), scope: "user" };
3404
+ return { dir: path10.join(os5.homedir(), ".ae-mcp"), scope: "user" };
2786
3405
  }
2787
3406
  function journalOverride() {
2788
3407
  const override = process.env.AE_MCP_HOME?.trim();
@@ -2791,20 +3410,20 @@ function journalOverride() {
2791
3410
  function journals() {
2792
3411
  const project = journalRoot();
2793
3412
  const user = userJournalRoot();
2794
- if (path9.resolve(project.dir) === path9.resolve(user.dir)) return [project];
3413
+ if (path10.resolve(project.dir) === path10.resolve(user.dir)) return [project];
2795
3414
  return [project, user];
2796
3415
  }
2797
3416
  function issuesDir(journal) {
2798
- return path9.join(journal.dir, "issues");
3417
+ return path10.join(journal.dir, "issues");
2799
3418
  }
2800
3419
  function journalFor(scope) {
2801
3420
  return scope === "user" ? userJournalRoot() : journalRoot();
2802
3421
  }
2803
3422
  function ensureJournalDir(journal) {
2804
3423
  const issues = issuesDir(journal);
2805
- fs8.mkdirSync(issues, { recursive: true });
2806
- const ignore = path9.join(journal.dir, ".gitignore");
2807
- if (!fs8.existsSync(ignore)) fs8.writeFileSync(ignore, "*\n", "utf8");
3424
+ fs9.mkdirSync(issues, { recursive: true });
3425
+ const ignore = path10.join(journal.dir, ".gitignore");
3426
+ if (!fs9.existsSync(ignore)) fs9.writeFileSync(ignore, "*\n", "utf8");
2808
3427
  return issues;
2809
3428
  }
2810
3429
  function slugify(text) {
@@ -2817,10 +3436,16 @@ function today() {
2817
3436
  function oneLine(text) {
2818
3437
  return text.replace(/\s+/g, " ").trim();
2819
3438
  }
3439
+ function clipErrorText(text) {
3440
+ if (!text) return void 0;
3441
+ const line = oneLine(stripDecorations(text));
3442
+ if (line.length === 0) return void 0;
3443
+ return line.length > ERROR_TEXT_CHARS ? line.slice(0, ERROR_TEXT_CHARS) : line;
3444
+ }
2820
3445
  function entryPath(id, journal) {
2821
- const dir = path9.resolve(issuesDir(journal));
2822
- const file = path9.resolve(dir, `${id}.md`);
2823
- if (path9.dirname(file) !== dir) throw new Error(`Invalid issue id: ${id}`);
3446
+ const dir = path10.resolve(issuesDir(journal));
3447
+ const file = path10.resolve(dir, `${id}.md`);
3448
+ if (path10.dirname(file) !== dir) throw new Error(`Invalid issue id: ${id}`);
2824
3449
  return file;
2825
3450
  }
2826
3451
  function render(entry) {
@@ -2829,18 +3454,22 @@ function render(entry) {
2829
3454
  `id: ${entry.id}`,
2830
3455
  `title: ${oneLine(entry.title)}`,
2831
3456
  `tools: ${entry.tools.join(", ")}`,
3457
+ `kind: ${entry.kind}`,
3458
+ `errorText: ${entry.errorText ?? ""}`,
2832
3459
  `firstSeen: ${entry.firstSeen}`,
2833
3460
  `lastSeen: ${entry.lastSeen}`,
3461
+ `firstVersion: ${entry.firstVersion ?? ""}`,
3462
+ `lastVersion: ${entry.lastVersion ?? ""}`,
2834
3463
  `occurrences: ${entry.occurrences}`,
2835
3464
  `reported: ${entry.reported}`,
2836
- `issueUrl: ${entry.issueUrl ?? ""}`,
2837
- "---",
2838
- "",
2839
- `## ${SECTION_SYMPTOM}`,
2840
- "",
2841
- entry.symptom.trim(),
2842
- ""
3465
+ `issueUrl: ${entry.issueUrl ?? ""}`
2843
3466
  ];
3467
+ if (entry.archived) {
3468
+ lines.push("archived: true");
3469
+ lines.push(`archivedReason: ${oneLine(entry.archivedReason ?? "")}`);
3470
+ lines.push(`archivedAt: ${entry.archivedAt ?? ""}`);
3471
+ }
3472
+ lines.push("---", "", `## ${SECTION_SYMPTOM}`, "", entry.symptom.trim(), "");
2844
3473
  if (entry.cause && entry.cause.trim().length > 0) {
2845
3474
  lines.push(`## ${SECTION_CAUSE}`, "", entry.cause.trim(), "");
2846
3475
  }
@@ -2861,6 +3490,10 @@ function readSections(body) {
2861
3490
  });
2862
3491
  return sections;
2863
3492
  }
3493
+ function parseKind(raw) {
3494
+ const kind = (raw ?? "").trim().toLowerCase();
3495
+ return ISSUE_KINDS.includes(kind) ? kind : "tool-bug";
3496
+ }
2864
3497
  function parse(text, fallbackId, scope = "project") {
2865
3498
  const meta = {};
2866
3499
  let body = text;
@@ -2874,6 +3507,7 @@ function parse(text, fallbackId, scope = "project") {
2874
3507
  body = text.slice(fm[0].length);
2875
3508
  }
2876
3509
  const sections = readSections(body);
3510
+ const nonEmpty = (v) => v && v.length > 0 ? v : void 0;
2877
3511
  const occurrences = Number.parseInt(meta.occurrences ?? "1", 10);
2878
3512
  return {
2879
3513
  id: meta.id || fallbackId,
@@ -2881,11 +3515,18 @@ function parse(text, fallbackId, scope = "project") {
2881
3515
  // The folder decides this, never the file — see IssueEntry.scope.
2882
3516
  scope,
2883
3517
  tools: (meta.tools ?? "").split(",").map((t) => t.trim()).filter((t) => t.length > 0),
3518
+ kind: parseKind(meta.kind),
3519
+ errorText: clipErrorText(meta.errorText),
2884
3520
  firstSeen: meta.firstSeen || "",
2885
3521
  lastSeen: meta.lastSeen || meta.firstSeen || "",
3522
+ firstVersion: nonEmpty(meta.firstVersion),
3523
+ lastVersion: nonEmpty(meta.lastVersion),
2886
3524
  occurrences: Number.isFinite(occurrences) && occurrences > 0 ? occurrences : 1,
2887
3525
  reported: meta.reported === "true",
2888
- issueUrl: meta.issueUrl && meta.issueUrl.length > 0 ? meta.issueUrl : void 0,
3526
+ issueUrl: nonEmpty(meta.issueUrl),
3527
+ archived: meta.archived === "true",
3528
+ archivedReason: nonEmpty(meta.archivedReason),
3529
+ archivedAt: nonEmpty(meta.archivedAt),
2889
3530
  // A hand-edited file with no recognised headings still has its text kept,
2890
3531
  // rather than being silently reduced to an empty entry.
2891
3532
  symptom: sections.get(SECTION_SYMPTOM.toLowerCase()) ?? (sections.size === 0 ? body.trim() : ""),
@@ -2895,23 +3536,152 @@ function parse(text, fallbackId, scope = "project") {
2895
3536
  }
2896
3537
  function readEntry(file, scope) {
2897
3538
  try {
2898
- return parse(fs8.readFileSync(file, "utf8"), path9.basename(file, ".md"), scope);
3539
+ return parse(fs9.readFileSync(file, "utf8"), path10.basename(file, ".md"), scope);
2899
3540
  } catch {
2900
3541
  return null;
2901
3542
  }
2902
3543
  }
3544
+ function writeEntry(entry, journal) {
3545
+ ensureJournalDir(journal);
3546
+ const file = entryPath(entry.id, journal);
3547
+ fs9.writeFileSync(file, render(entry), "utf8");
3548
+ return file;
3549
+ }
3550
+ function parseVersion(v) {
3551
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v);
3552
+ if (!m) return null;
3553
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
3554
+ }
3555
+ function compareVersions(a, b) {
3556
+ const pa = parseVersion(a);
3557
+ const pb = parseVersion(b);
3558
+ if (!pa || !pb) return null;
3559
+ for (let i = 0; i < 3; i++) {
3560
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1;
3561
+ }
3562
+ if (pa.pre && !pb.pre) return -1;
3563
+ if (!pa.pre && pb.pre) return 1;
3564
+ if (pa.pre && pb.pre) return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0;
3565
+ return 0;
3566
+ }
3567
+ function daysBetween(from, to) {
3568
+ const a = Date.parse(from);
3569
+ const b = Date.parse(to);
3570
+ if (Number.isNaN(a) || Number.isNaN(b)) return null;
3571
+ return Math.floor((b - a) / 864e5);
3572
+ }
3573
+ function archiveContext() {
3574
+ return { today: today(), version: packageVersion() };
3575
+ }
3576
+ function archiveReason(entry, ctx) {
3577
+ if (entry.archived) return entry.archivedReason || "archived by hand";
3578
+ const age = daysBetween(entry.lastSeen, ctx.today);
3579
+ if (age !== null && age >= ARCHIVE_AFTER_DAYS) return `not seen for ${age} days`;
3580
+ if (entry.kind !== "ae-quirk" && entry.lastVersion) {
3581
+ const cmp = compareVersions(entry.lastVersion, ctx.version);
3582
+ if (cmp !== null && cmp < 0) {
3583
+ return `last seen on ${entry.lastVersion}; this server is ${ctx.version}, so it is presumed fixed until seen again`;
3584
+ }
3585
+ }
3586
+ return null;
3587
+ }
3588
+ function toolsOverlap(a, b) {
3589
+ const set = new Set(a.map((t) => t.trim().toLowerCase()));
3590
+ return b.some((t) => set.has(t.trim().toLowerCase()));
3591
+ }
3592
+ function uniqueTools(...lists) {
3593
+ const seen = /* @__PURE__ */ new Set();
3594
+ const out = [];
3595
+ for (const list of lists) {
3596
+ for (const t of list ?? []) {
3597
+ const key = t.trim().toLowerCase();
3598
+ if (key.length === 0 || seen.has(key)) continue;
3599
+ seen.add(key);
3600
+ out.push(t.trim());
3601
+ }
3602
+ }
3603
+ return out;
3604
+ }
3605
+ var DECORATION_CUTS = [
3606
+ /(?:^|\s)at line \d+ of /,
3607
+ /(?:^|\s)After Effects reported line/,
3608
+ /(?:^|\s)Everything before the failure already ran/,
3609
+ /\s\|\|\s*Changed before it stopped/,
3610
+ /(?:^|\s)Known from earlier sessions:/
3611
+ ];
3612
+ function stripDecorations(text) {
3613
+ let out = text.replace(/^\s*AE\s*:\s*/i, "");
3614
+ for (const cut of DECORATION_CUTS) {
3615
+ const m = cut.exec(out);
3616
+ if (m) out = out.slice(0, m.index);
3617
+ }
3618
+ return out.replace(/\s*\(line \d+\)\s*$/, "").trim();
3619
+ }
3620
+ function normalizeErrorText(text, opts = {}) {
3621
+ let t = stripDecorations(text).toLowerCase().replace(/\b[a-z]:\\[^\s'"`]+/g, " ").replace(/file:\/\/[^\s'"`]+/g, " ").replace(/(?:^|[\s(=:,])\/[^\s'"`)]+/g, " ");
3622
+ if (!opts.keepQuotes) {
3623
+ t = t.replace(/"[^"\n]*"|`[^`\n]*`/g, " ").replace(/(?<![a-z])'[^'\n]*'(?![a-z])/g, " ");
3624
+ }
3625
+ return t.replace(/[^a-z]+/g, "");
3626
+ }
3627
+ var MATCH_MIN_CHARS = 20;
3628
+ var MATCH_PREFIX_CHARS = 60;
3629
+ function errorTextsMatch(a, b) {
3630
+ if (!a || !b) return false;
3631
+ const na = normalizeErrorText(a);
3632
+ const nb = normalizeErrorText(b);
3633
+ if (na.length === 0 || nb.length === 0) return false;
3634
+ if (na === nb) return true;
3635
+ const shorter = Math.min(na.length, nb.length);
3636
+ if (shorter >= MATCH_MIN_CHARS && (na.startsWith(nb) || nb.startsWith(na))) return true;
3637
+ return shorter >= MATCH_PREFIX_CHARS && na.slice(0, MATCH_PREFIX_CHARS) === nb.slice(0, MATCH_PREFIX_CHARS);
3638
+ }
3639
+ function entryMatchesFailure(entry, tool, errorText) {
3640
+ if (!toolsOverlap(entry.tools, [tool])) return false;
3641
+ if (entry.errorText) return errorTextsMatch(entry.errorText, errorText);
3642
+ const needle = normalizeErrorText(errorText).slice(0, MATCH_PREFIX_CHARS);
3643
+ if (needle.length < MATCH_MIN_CHARS) return false;
3644
+ return normalizeErrorText(entry.symptom, { keepQuotes: true }).includes(needle) || normalizeErrorText(entry.symptom).includes(needle);
3645
+ }
2903
3646
  function logIssue(input) {
2904
3647
  const journal = journalFor(input.scope ?? "project");
2905
- const id = slugify(input.title);
2906
- const file = entryPath(id, journal);
2907
- const existing = fs8.existsSync(file) ? readEntry(file, journal.scope) : null;
3648
+ const version = packageVersion();
3649
+ const tools = uniqueTools(input.tools);
3650
+ let id = slugify(input.title);
3651
+ let existing = fs9.existsSync(entryPath(id, journal)) ? readEntry(entryPath(id, journal), journal.scope) : null;
3652
+ let mergedBy;
3653
+ if (existing) {
3654
+ mergedBy = "title";
3655
+ } else if (input.errorText && tools.length > 0) {
3656
+ const errorText = input.errorText;
3657
+ const twin = readJournalEntries(journal).find(
3658
+ (e) => e.id !== id && tools.some((t) => entryMatchesFailure(e, t, errorText))
3659
+ );
3660
+ if (twin) {
3661
+ existing = twin;
3662
+ id = twin.id;
3663
+ mergedBy = "errorText";
3664
+ }
3665
+ }
3666
+ const reopened = existing?.archived === true;
3667
+ const incomingError = clipErrorText(input.errorText);
2908
3668
  const entry = {
2909
3669
  id,
2910
- title: oneLine(input.title),
3670
+ // On an error-text merge the existing title stays: it is the id, and the
3671
+ // one the index has been showing.
3672
+ title: mergedBy === "errorText" ? existing.title : oneLine(input.title),
2911
3673
  scope: journal.scope,
2912
- tools: input.tools ?? existing?.tools ?? [],
3674
+ tools: uniqueTools(existing?.tools, tools),
3675
+ kind: input.kind ?? existing?.kind ?? "tool-bug",
3676
+ // The recorded text is the one that has been matching, so a re-log of the
3677
+ // same error — a different number, a different name, pasted with its
3678
+ // decorations — leaves it alone. Only a genuinely different error under
3679
+ // this title replaces it: that is the caller's statement, not a paste.
3680
+ errorText: incomingError && existing?.errorText && errorTextsMatch(existing.errorText, incomingError) ? existing.errorText : incomingError ?? existing?.errorText,
2913
3681
  firstSeen: existing?.firstSeen || today(),
2914
3682
  lastSeen: today(),
3683
+ firstVersion: existing?.firstVersion || version,
3684
+ lastVersion: version,
2915
3685
  // Repeats are worth counting: an entry seen five times is the one most
2916
3686
  // worth reporting, and the count is the only evidence of that.
2917
3687
  occurrences: (existing?.occurrences ?? 0) + 1,
@@ -2921,48 +3691,58 @@ function logIssue(input) {
2921
3691
  // separate claims, so reporting one says nothing about the other.
2922
3692
  reported: existing?.reported ?? false,
2923
3693
  issueUrl: existing?.issueUrl,
3694
+ // A deliberate new sighting outranks a retirement: the entry was archived
3695
+ // as gone, and here it is. The flag clears and the result says so.
3696
+ archived: false,
2924
3697
  symptom: input.symptom,
2925
3698
  // A cause worked out once is not lost because a later sighting was logged
2926
3699
  // without one.
2927
3700
  cause: input.cause ?? existing?.cause,
2928
3701
  workaround: input.workaround
2929
3702
  };
2930
- ensureJournalDir(journal);
2931
- fs8.writeFileSync(file, render(entry), "utf8");
2932
- const alsoIn = journals().filter((j) => j.scope !== journal.scope).filter((j) => fs8.existsSync(entryPath(id, j))).map((j) => j.scope);
3703
+ const file = writeEntry(entry, journal);
3704
+ const alsoIn = journals().filter((j) => j.scope !== journal.scope).filter((j) => fs9.existsSync(entryPath(id, j))).map((j) => j.scope);
2933
3705
  return {
2934
3706
  id,
3707
+ title: entry.title,
2935
3708
  path: file,
2936
3709
  scope: journal.scope,
3710
+ kind: entry.kind,
2937
3711
  occurrences: entry.occurrences,
2938
3712
  previouslyLogged: existing !== null,
3713
+ ...mergedBy ? { mergedBy } : {},
2939
3714
  reported: entry.reported,
2940
3715
  issueUrl: entry.issueUrl,
2941
- ...alsoIn.length > 0 ? { alsoIn } : {}
3716
+ lastVersion: version,
3717
+ ...reopened ? { reopened: true, previousArchiveReason: existing?.archivedReason } : {},
3718
+ ...alsoIn.length > 0 ? { alsoIn } : {},
3719
+ ...mergedBy === "errorText" ? {
3720
+ note: `Merged into the existing entry "${entry.title}" (${journal.scope}:${id}): same tool, same error text. The title you passed was not used \u2014 reuse this one to extend it again.`
3721
+ } : {}
2942
3722
  };
2943
3723
  }
2944
- var SUMMARY_CHARS = 160;
2945
3724
  var DEFAULT_LIMIT = 50;
2946
- function summarize(entry) {
2947
- const text = oneLine(entry.symptom || entry.workaround || "");
2948
- return text.length > SUMMARY_CHARS ? `${text.slice(0, SUMMARY_CHARS).trimEnd()}\u2026` : text;
3725
+ function withVerdict(e, reason) {
3726
+ return reason === null ? { ...e, archived: false, archivedReason: void 0 } : { ...e, archived: true, archivedReason: reason };
2949
3727
  }
2950
- function toIndexEntry(e) {
3728
+ function toIndexEntry(e, reason) {
2951
3729
  return {
2952
3730
  id: e.id,
2953
3731
  title: e.title,
2954
3732
  scope: e.scope,
2955
3733
  tools: e.tools,
3734
+ kind: e.kind,
2956
3735
  lastSeen: e.lastSeen,
3736
+ ...e.lastVersion ? { lastVersion: e.lastVersion } : {},
2957
3737
  occurrences: e.occurrences,
2958
3738
  reported: e.reported,
2959
- summary: summarize(e)
3739
+ ...reason !== null ? { archived: true, archivedReason: reason } : {}
2960
3740
  };
2961
3741
  }
2962
3742
  function readJournalEntries(journal) {
2963
3743
  const dir = issuesDir(journal);
2964
3744
  try {
2965
- return fs8.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => readEntry(path9.join(dir, f), journal.scope)).filter((e) => e !== null);
3745
+ return fs9.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => readEntry(path10.join(dir, f), journal.scope)).filter((e) => e !== null);
2966
3746
  } catch {
2967
3747
  return [];
2968
3748
  }
@@ -2981,7 +3761,8 @@ function resolveEntry(raw, entries) {
2981
3761
  if (qualified) {
2982
3762
  const scope = qualified[1].toLowerCase();
2983
3763
  const slug2 = slugify(qualified[2]);
2984
- const hit = entries.find((e) => e.scope === scope && e.id === slug2);
3764
+ const inScope = (e) => e.scope === scope || scope === "project" && e.scope === "home";
3765
+ const hit = entries.find((e) => inScope(e) && e.id === slug2);
2985
3766
  if (hit) return { found: hit, alsoIn: [] };
2986
3767
  }
2987
3768
  const slug = slugify(raw);
@@ -3002,6 +3783,7 @@ function listIssues(options = {}) {
3002
3783
  const consulted = selectedJournals(filter);
3003
3784
  const perJournal = consulted.map((j) => ({ journal: j, entries: readJournalEntries(j) }));
3004
3785
  const entries = perJournal.flatMap((p) => p.entries);
3786
+ const ctx = options.context ?? archiveContext();
3005
3787
  const envelope = {
3006
3788
  dir: issuesDir(project),
3007
3789
  scope: project.scope,
@@ -3012,7 +3794,7 @@ function listIssues(options = {}) {
3012
3794
  })),
3013
3795
  repo: REPO,
3014
3796
  newIssueUrl: NEW_ISSUE_URL,
3015
- serverVersion: packageVersion(),
3797
+ serverVersion: ctx.version,
3016
3798
  platform: process.platform
3017
3799
  };
3018
3800
  const wantedId = options.id?.trim();
@@ -3027,7 +3809,7 @@ function listIssues(options = {}) {
3027
3809
  ...envelope,
3028
3810
  detail: "full",
3029
3811
  count: 1,
3030
- issues: [resolved.found],
3812
+ issues: [withVerdict(resolved.found, archiveReason(resolved.found, ctx))],
3031
3813
  // Same slug in the other journal: say so, and name the call that opens it,
3032
3814
  // rather than letting one of the two silently win.
3033
3815
  ...resolved.alsoIn.length > 0 ? {
@@ -3038,24 +3820,44 @@ function listIssues(options = {}) {
3038
3820
  const status = options.status ?? "all";
3039
3821
  const wanted = options.tool?.trim().toLowerCase();
3040
3822
  const terms = (options.query ?? "").toLowerCase().split(/\s+/).filter((t) => t.length > 0);
3041
- const filtered = entries.filter((e) => {
3823
+ const namesTool = (e) => wanted !== void 0 && e.tools.some((t) => t.toLowerCase() === wanted);
3824
+ const matched = entries.filter((e) => {
3042
3825
  const byStatus = status === "all" ? true : status === "reported" ? e.reported : !e.reported;
3043
3826
  if (!byStatus) return false;
3044
3827
  if (terms.length > 0 && !matchesQuery(e, terms)) return false;
3045
3828
  if (!wanted) return true;
3046
- return e.tools.some((t) => t.toLowerCase() === wanted) || e.title.toLowerCase().includes(wanted);
3829
+ return namesTool(e) || e.title.toLowerCase().includes(wanted);
3047
3830
  });
3048
- filtered.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences);
3831
+ const verdicts = new Map(matched.map((e) => [e, archiveReason(e, ctx)]));
3832
+ const archivedCount = matched.filter((e) => verdicts.get(e) !== null).length;
3833
+ const filtered = options.includeArchived ? matched : matched.filter((e) => verdicts.get(e) === null);
3834
+ filtered.sort(
3835
+ (a, b) => Number(namesTool(b)) - Number(namesTool(a)) || b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences
3836
+ );
3049
3837
  const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_LIMIT;
3050
3838
  const shown = filtered.slice(0, limit);
3051
3839
  const omitted = filtered.length - shown.length;
3052
3840
  const detail = options.detail ?? "index";
3053
3841
  const first = shown[0];
3842
+ const hidden = options.includeArchived ? 0 : archivedCount;
3843
+ const nextParts = [];
3844
+ if (detail === "index" && first) {
3845
+ nextParts.push(
3846
+ `list_known_issues({ id: "${first.scope}:${first.id}" }) for the cause and the workaround. Prefix any id from this list with its own scope.`
3847
+ );
3848
+ }
3849
+ if (omitted > 0) nextParts.push(`${omitted} more matched \u2014 narrow with tool/query, or raise limit.`);
3850
+ if (hidden > 0) {
3851
+ nextParts.push(
3852
+ `${hidden} archived ${hidden === 1 ? "entry is" : "entries are"} hidden (not seen for ${ARCHIVE_AFTER_DAYS} days, last seen on an older server, or retired with archive_issue) \u2014 includeArchived: true lists them.`
3853
+ );
3854
+ }
3054
3855
  return {
3055
3856
  ...envelope,
3056
3857
  detail,
3057
3858
  count: filtered.length,
3058
- issues: detail === "full" ? shown : shown.map(toIndexEntry),
3859
+ archivedCount,
3860
+ issues: detail === "full" ? shown.map((e) => withVerdict(e, verdicts.get(e) ?? null)) : shown.map((e) => toIndexEntry(e, verdicts.get(e) ?? null)),
3059
3861
  // Truncation is named and counted. A short answer that looked complete
3060
3862
  // would be the same class of lie as a swallowed error.
3061
3863
  ...omitted > 0 ? { omitted } : {},
@@ -3064,12 +3866,10 @@ function listIssues(options = {}) {
3064
3866
  // how to reach it, every time there is one to reach — and since ids are
3065
3867
  // only unique within a journal, spell the scope-qualified form out on a
3066
3868
  // real entry rather than leaving the caller to guess the syntax.
3067
- ...detail === "index" && first ? {
3068
- next: `list_known_issues({ id: "${first.scope}:${first.id}" }) for the cause and the workaround. Prefix any id from this list with its own scope.` + (omitted > 0 ? ` ${omitted} more matched \u2014 narrow with tool/query, or raise limit.` : "")
3069
- } : {}
3869
+ ...nextParts.length > 0 ? { next: nextParts.join(" ") } : {}
3070
3870
  };
3071
3871
  }
3072
- function markReported(id, url) {
3872
+ function locate(id) {
3073
3873
  const entries = readAllEntries();
3074
3874
  const resolved = resolveEntry(id, entries);
3075
3875
  if (!resolved) {
@@ -3080,12 +3880,130 @@ function markReported(id, url) {
3080
3880
  const entry = resolved.found;
3081
3881
  const journal = journals().find((j) => j.scope === entry.scope);
3082
3882
  if (!journal) throw new Error(`Journal for scope "${entry.scope}" is no longer readable.`);
3883
+ return { entry, journal };
3884
+ }
3885
+ function markReported(id, url) {
3886
+ const { entry, journal } = locate(id);
3083
3887
  entry.reported = true;
3084
3888
  if (url) entry.issueUrl = oneLine(url);
3085
- fs8.writeFileSync(entryPath(entry.id, journal), render(entry), "utf8");
3889
+ writeEntry(entry, journal);
3890
+ return entry;
3891
+ }
3892
+ function archiveIssue(id, reason) {
3893
+ const { entry, journal } = locate(id);
3894
+ entry.archived = true;
3895
+ entry.archivedReason = oneLine(reason);
3896
+ entry.archivedAt = today();
3897
+ writeEntry(entry, journal);
3086
3898
  return entry;
3087
3899
  }
3088
3900
 
3901
+ // src/issues/failures.ts
3902
+ import fs10 from "node:fs";
3903
+ import path11 from "node:path";
3904
+ var MAX_MATCHES = 3;
3905
+ var JournalCache = class {
3906
+ files = /* @__PURE__ */ new Map();
3907
+ /** Files parsed since construction — the number the test watches to prove the cache is one. */
3908
+ reads = 0;
3909
+ entries(refs = journals()) {
3910
+ const out = [];
3911
+ const seen = /* @__PURE__ */ new Set();
3912
+ for (const journal of refs) {
3913
+ const dir = issuesDir(journal);
3914
+ let names;
3915
+ try {
3916
+ names = fs10.readdirSync(dir).filter((f) => f.endsWith(".md"));
3917
+ } catch {
3918
+ continue;
3919
+ }
3920
+ for (const name of names) {
3921
+ const file = path11.join(dir, name);
3922
+ seen.add(file);
3923
+ let st;
3924
+ try {
3925
+ st = fs10.statSync(file);
3926
+ } catch {
3927
+ continue;
3928
+ }
3929
+ const cached = this.files.get(file);
3930
+ if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
3931
+ if (cached.entry) out.push(cached.entry);
3932
+ continue;
3933
+ }
3934
+ this.reads++;
3935
+ const entry = readEntry(file, journal.scope);
3936
+ this.files.set(file, { size: st.size, mtimeMs: st.mtimeMs, entry });
3937
+ if (entry) out.push(entry);
3938
+ }
3939
+ }
3940
+ for (const file of this.files.keys()) if (!seen.has(file)) this.files.delete(file);
3941
+ return out;
3942
+ }
3943
+ /**
3944
+ * After this module writes an entry, the cached copy is the one it wrote —
3945
+ * so the next lookup does not re-parse a file whose content it already holds
3946
+ * even on a filesystem whose mtime resolution would not show the write.
3947
+ */
3948
+ refresh(file, entry) {
3949
+ try {
3950
+ const st = fs10.statSync(file);
3951
+ this.files.set(file, { size: st.size, mtimeMs: st.mtimeMs, entry });
3952
+ } catch {
3953
+ this.files.delete(file);
3954
+ }
3955
+ }
3956
+ };
3957
+ function matchFailure(cache, tool, errorText, ctx = archiveContext(), refs = journals()) {
3958
+ const hits = cache.entries(refs).filter((e) => entryMatchesFailure(e, tool, errorText)).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences);
3959
+ return {
3960
+ matches: hits.slice(0, MAX_MATCHES).map((entry) => ({ entry, archivedReason: archiveReason(entry, ctx) })),
3961
+ total: hits.length
3962
+ };
3963
+ }
3964
+ function describeMatches(found) {
3965
+ const lines = found.matches.map(({ entry, archivedReason }) => {
3966
+ const where = `${entry.scope}:${entry.id}`;
3967
+ const tail = archivedReason ? ` (archived: ${archivedReason})` : "";
3968
+ return `Known from earlier sessions: ${where} \u2014 ${entry.title}${tail}`;
3969
+ });
3970
+ const more = found.total - found.matches.length;
3971
+ if (more > 0) lines.push(`\u2026and ${more} more matching ${more === 1 ? "entry" : "entries"}.`);
3972
+ const first = found.matches[0];
3973
+ lines.push(
3974
+ `list_known_issues({ id: "${first.entry.scope}:${first.entry.id}" }) has the cause and the workaround.`
3975
+ );
3976
+ return lines.join("\n");
3977
+ }
3978
+ function bumpSighting(cache, match, ctx) {
3979
+ const { entry } = match;
3980
+ if (entry.lastSeen === ctx.today && entry.lastVersion === ctx.version) return;
3981
+ const journal = journals().find((j) => j.scope === entry.scope);
3982
+ if (!journal) return;
3983
+ try {
3984
+ entry.lastSeen = ctx.today;
3985
+ entry.lastVersion = ctx.version;
3986
+ const file = writeEntry(entry, journal);
3987
+ cache.refresh(file, entry);
3988
+ } catch (e) {
3989
+ logger.warn(`Could not update journal entry ${entry.scope}:${entry.id} after a matching failure: ${e.message}`);
3990
+ }
3991
+ }
3992
+ function annotateFailure(cache, tool, message, ctx) {
3993
+ try {
3994
+ const context = ctx ?? archiveContext();
3995
+ const found = matchFailure(cache, tool, message, context);
3996
+ if (found.matches.length === 0) return message;
3997
+ for (const match of found.matches) bumpSighting(cache, match, context);
3998
+ return `${message}
3999
+
4000
+ ${describeMatches(found)}`;
4001
+ } catch (e) {
4002
+ logger.warn(`Journal lookup for a failed ${tool} call was skipped: ${e.message}`);
4003
+ return message;
4004
+ }
4005
+ }
4006
+
3089
4007
  // src/style/summary.ts
3090
4008
  var HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
3091
4009
  var SECTION_KEYWORDS = [
@@ -3308,7 +4226,8 @@ var SERVER_OPS = /* @__PURE__ */ new Set([
3308
4226
  "ae_guide",
3309
4227
  "log_issue",
3310
4228
  "list_known_issues",
3311
- "mark_issue_reported"
4229
+ "mark_issue_reported",
4230
+ "archive_issue"
3312
4231
  ]);
3313
4232
  var SNAPSHOT_OPS = /* @__PURE__ */ new Set(["snapshot_comp", "diff_comp"]);
3314
4233
  function isWriteOp(name) {
@@ -3319,9 +4238,9 @@ var GUIDE_URI_PREFIX = "ae://guide/";
3319
4238
  var AwaitJobSchema = schemas_exports.AwaitJob;
3320
4239
  var GetJobSchema = schemas_exports.GetJob;
3321
4240
  var CancelJobSchema = schemas_exports.CancelJob;
3322
- function createServer() {
4241
+ function createServer(opts = {}) {
3323
4242
  const server = new Server(
3324
- { name: "after-effects-mcp", version: "0.4.0" },
4243
+ { name: "after-effects-mcp", version: "0.5.0" },
3325
4244
  {
3326
4245
  capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
3327
4246
  // Clients that honour this fold it into the system prompt, which is the
@@ -3330,16 +4249,26 @@ function createServer() {
3330
4249
  instructions: SERVER_INSTRUCTIONS
3331
4250
  }
3332
4251
  );
3333
- const bridge = new HttpClient();
4252
+ const bridge = opts.bridge ?? new HttpClient();
3334
4253
  const jobs = new JobManager();
3335
4254
  const writes = new WriteQueue();
3336
4255
  const snapshots = new SnapshotStore();
3337
- const ws = new WsClient(bridge.port, jobs);
4256
+ const ws = new WsClient(bridge, jobs);
3338
4257
  ws.start();
3339
4258
  const panelGate = createPanelGate(bridge);
4259
+ const journalCache = new JournalCache();
4260
+ const fail = (tool, message) => errorResult(annotateFailure(journalCache, tool, message));
3340
4261
  bridge.health().then(
3341
4262
  (h) => logger.info(`Bridge healthy on port ${h.port}`),
3342
- (e) => logger.warn(`Bridge not reachable yet: ${e.message}`)
4263
+ async (e) => {
4264
+ logger.warn(`Bridge not reachable yet: ${e.message}`);
4265
+ if (!(e instanceof BridgeUnreachableError)) return;
4266
+ const located = await bridge.rediscover().catch(() => null);
4267
+ if (located?.found && located.found.port !== bridge.port) {
4268
+ await bridge.switchPort(located.found.port);
4269
+ logger.info(`Bridge healthy on port ${bridge.port}`);
4270
+ }
4271
+ }
3343
4272
  );
3344
4273
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3345
4274
  const tools = Object.keys(OpSchemas2).map((name) => ({
@@ -3395,8 +4324,13 @@ function createServer() {
3395
4324
  try {
3396
4325
  if (name === "await_job") {
3397
4326
  const a = AwaitJobSchema.parse(rawArgs);
3398
- const st = await jobs.waitFor(a.jobId, a.timeoutMs ?? 6e5);
3399
- return textResult(st);
4327
+ const forward = progressToken !== void 0 ? forwardJobProgress(jobs, a.jobId, progressToken, extra) : null;
4328
+ try {
4329
+ const st = await jobs.waitFor(a.jobId, a.timeoutMs ?? 6e5);
4330
+ return textResult(st);
4331
+ } finally {
4332
+ await forward?.settle();
4333
+ }
3400
4334
  }
3401
4335
  if (name === "get_job") {
3402
4336
  const a = GetJobSchema.parse(rawArgs);
@@ -3409,14 +4343,15 @@ function createServer() {
3409
4343
  jobs.cancel(a.jobId);
3410
4344
  return textResult({ ok: true });
3411
4345
  }
4346
+ const setupOpts = () => ({ opPort: bridge.port, candidates: [...bridge.candidates(), ...diagnosticPortCandidates()] });
3412
4347
  if (name === "check_setup") {
3413
- return textResult(await checkSetup());
4348
+ return textResult(await checkSetup(setupOpts()));
3414
4349
  }
3415
4350
  if (name === "setup_panel") {
3416
4351
  const a = schemas_exports.SetupPanel.parse(rawArgs);
3417
4352
  const installed = await installPanel({ enableDebugMode: a.enableDebugMode, force: a.force });
3418
4353
  panelGate.invalidate();
3419
- return textResult({ ...installed, setup: await checkSetup() });
4354
+ return textResult({ ...installed, setup: await checkSetup(setupOpts()) });
3420
4355
  }
3421
4356
  if (name === "init_project") {
3422
4357
  const a = schemas_exports.InitProject.parse(rawArgs);
@@ -3435,6 +4370,15 @@ function createServer() {
3435
4370
  const a = schemas_exports.AeGuide.parse(rawArgs);
3436
4371
  const guide = getGuide(a.topic);
3437
4372
  if (!guide) return errorResult(`Unknown guide topic: ${a.topic}`);
4373
+ if (a.topic === "whats-new") {
4374
+ const rendered = renderWhatsNew(guide.body, { since: a.since, serverVersion: packageVersion() });
4375
+ return { content: [{ type: "text", text: rendered.text }] };
4376
+ }
4377
+ if (a.since !== void 0) {
4378
+ return errorResult(
4379
+ `\`since\` filters the whats-new topic only; the ${a.topic} topic has no release sections to filter. Call ae_guide({topic: "${a.topic}"}) without \`since\`, or ae_guide({topic: "whats-new", since: "${a.since}"}) for what changed.`
4380
+ );
4381
+ }
3438
4382
  return { content: [{ type: "text", text: guide.body }] };
3439
4383
  }
3440
4384
  if (name === "log_issue") {
@@ -3455,7 +4399,10 @@ function createServer() {
3455
4399
  // Both journals by default — the whole point of the user one is
3456
4400
  // that a fresh project folder does not start ignorant.
3457
4401
  scope: a.scope ?? "all",
3458
- limit: a.limit
4402
+ limit: a.limit,
4403
+ // Hidden unless asked: an archived entry is one that stopped
4404
+ // biting, was superseded by a release, or was retired on purpose.
4405
+ includeArchived: a.includeArchived ?? false
3459
4406
  })
3460
4407
  );
3461
4408
  }
@@ -3464,21 +4411,34 @@ function createServer() {
3464
4411
  const entry = markReported(a.id, a.url);
3465
4412
  return textResult({ ok: true, id: entry.id, scope: entry.scope, reported: true, issueUrl: entry.issueUrl });
3466
4413
  }
4414
+ if (name === "archive_issue") {
4415
+ const a = schemas_exports.ArchiveIssue.parse(rawArgs);
4416
+ const entry = archiveIssue(a.id, a.reason);
4417
+ return textResult({
4418
+ ok: true,
4419
+ id: entry.id,
4420
+ scope: entry.scope,
4421
+ archived: true,
4422
+ reason: entry.archivedReason,
4423
+ archivedAt: entry.archivedAt,
4424
+ note: "The file is kept and can be reopened by a later log_issue with the same title or the same tool and error text."
4425
+ });
4426
+ }
3467
4427
  } catch (e) {
3468
- return errorResult(invalidArgsText(name, e));
4428
+ return fail(name, invalidArgsText(name, e));
3469
4429
  }
3470
4430
  }
3471
4431
  let args;
3472
4432
  try {
3473
4433
  args = OpSchemas2[name].parse(rawArgs);
3474
4434
  } catch (e) {
3475
- return errorResult(invalidArgsText(name, e));
4435
+ return fail(name, invalidArgsText(name, e));
3476
4436
  }
3477
4437
  if (name === "run_jsx") {
3478
4438
  try {
3479
4439
  args = resolveRunJsxSource(args);
3480
4440
  } catch (e) {
3481
- return errorResult(e.message);
4441
+ return fail(name, e.message);
3482
4442
  }
3483
4443
  }
3484
4444
  const staleness = await panelGate.check();
@@ -3488,24 +4448,16 @@ function createServer() {
3488
4448
  try {
3489
4449
  lease = await writes.acquire(name, extra?.signal);
3490
4450
  } catch (e) {
3491
- return errorResult(e.message);
4451
+ return fail(name, e.message);
3492
4452
  }
3493
4453
  }
3494
4454
  const wait = lease?.wait ?? null;
3495
4455
  try {
3496
4456
  if (SNAPSHOT_OPS.has(name)) return await runSnapshotOp(name, args, bridge, snapshots);
3497
- const result = await bridge.runOp(name, args, progressToken);
4457
+ const result = await bridge.runOp(name, args);
3498
4458
  if (ASYNC_OPS.has(name) && isAsyncEnvelope(result)) {
3499
4459
  const env = result;
3500
4460
  jobs.register(env.jobId, env.total);
3501
- if (progressToken !== void 0) {
3502
- jobs.bindProgressEmitter(env.jobId, (jid, progress, total, message) => {
3503
- void server.notification({
3504
- method: "notifications/progress",
3505
- params: { progressToken, progress, total, message }
3506
- });
3507
- });
3508
- }
3509
4461
  lease?.extendUntil(jobs.waitFor(env.jobId, writes.holdCeilingMs));
3510
4462
  return textResult(
3511
4463
  {
@@ -3515,7 +4467,7 @@ function createServer() {
3515
4467
  chunkSize: env.chunkSize,
3516
4468
  undoStepsEstimate: env.undoStepsEstimate,
3517
4469
  undoGroupName: env.undoGroupName,
3518
- note: env.note
4470
+ note: withProgressNote(env.note, env.jobId)
3519
4471
  },
3520
4472
  wait
3521
4473
  );
@@ -3560,23 +4512,41 @@ function createServer() {
3560
4512
  }
3561
4513
  return textResult(result, wait);
3562
4514
  } catch (e) {
3563
- if (e instanceof BridgeTimeoutError) return errorResult(e.message);
3564
- if (e instanceof BridgeUnreachableError) return errorResult(e.message);
4515
+ if (e instanceof BridgeTimeoutError) return fail(name, e.message);
4516
+ if (e instanceof BridgeUnreachableError) return fail(name, e.message);
3565
4517
  if (e instanceof AeError) {
3566
- if (e.code) return errorResult(e.message);
4518
+ if (e.code) return fail(name, e.message);
3567
4519
  if (/^Unknown op: /.test(e.message)) {
3568
4520
  panelGate.invalidate();
3569
4521
  return errorResult(unknownOpMessage(name));
3570
4522
  }
3571
- return errorResult(aeErrorText(e));
4523
+ return fail(name, aeErrorText(e));
3572
4524
  }
3573
- return errorResult(e.message);
4525
+ return fail(name, e.message);
3574
4526
  } finally {
3575
4527
  lease?.release();
3576
4528
  }
3577
4529
  });
3578
4530
  return server;
3579
4531
  }
4532
+ function forwardJobProgress(jobs, jobId, progressToken, extra) {
4533
+ const inFlight = /* @__PURE__ */ new Set();
4534
+ const unbind = jobs.bindProgressEmitter(jobId, (_jid, progress, total, message) => {
4535
+ const p = extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, total, message } }).catch((e) => logger.warn(`progress notification for job ${jobId} was not sent: ${e.message}`));
4536
+ inFlight.add(p);
4537
+ void p.finally(() => inFlight.delete(p));
4538
+ });
4539
+ return {
4540
+ async settle() {
4541
+ unbind();
4542
+ await Promise.all(inFlight);
4543
+ }
4544
+ };
4545
+ }
4546
+ function withProgressNote(note, jobId) {
4547
+ const progress = `Follow it with await_job({jobId: "${jobId}"}), which blocks until the batch finishes and returns its result. Progress is delivered on THAT call: send it with a progressToken and notifications/progress arrive while it waits, every one before its response. None can ride on this run_batch call \u2014 its response is already back before the first chunk runs. get_job({jobId: "${jobId}"}) reads the same state without a token.`;
4548
+ return note ? `${note} ${progress}` : progress;
4549
+ }
3580
4550
  async function runSnapshotOp(name, args, bridge, snapshots) {
3581
4551
  if (name === "snapshot_comp") {
3582
4552
  const a = args;
@@ -3619,6 +4589,10 @@ function createPanelGate(bridge) {
3619
4589
  const RECHECK_MS = 6e4;
3620
4590
  let verdict = null;
3621
4591
  let checkedAt = 0;
4592
+ bridge.onPortChange(() => {
4593
+ verdict = null;
4594
+ checkedAt = 0;
4595
+ });
3622
4596
  return {
3623
4597
  /** The message to return instead of forwarding, or null to proceed. */
3624
4598
  async check() {
@@ -3687,8 +4661,8 @@ function toDraft2020(node) {
3687
4661
  return out;
3688
4662
  }
3689
4663
  function withCrossFieldConstraints(zodSchema, jsonSchema, opName) {
3690
- for (const { path: path10, rule } of schemas_exports.crossFieldRulesIn(zodSchema)) {
3691
- const where = path10.join(".") || "(root)";
4664
+ for (const { path: path12, rule } of schemas_exports.crossFieldRulesIn(zodSchema)) {
4665
+ const where = path12.join(".") || "(root)";
3692
4666
  if (!rule) {
3693
4667
  logger.warn(
3694
4668
  `${opName} carries a refinement at ${where} that is not a declared cross-field rule. It is enforced on every call and absent from the schema the model is shown, so an agent can only learn it by having a call rejected. Declare it with crossField() in packages/shared/src/schemas.ts.`
@@ -3696,7 +4670,7 @@ function withCrossFieldConstraints(zodSchema, jsonSchema, opName) {
3696
4670
  continue;
3697
4671
  }
3698
4672
  let node = jsonSchema;
3699
- for (const step of path10) {
4673
+ for (const step of path12) {
3700
4674
  node = step === schemas_exports.ARRAY_ELEMENT ? node?.items : node?.properties?.[step];
3701
4675
  if (!node || typeof node !== "object") break;
3702
4676
  }
@@ -3768,7 +4742,7 @@ ${USAGE}`);
3768
4742
  await server.connect(transport);
3769
4743
  logger.info("MCP server running on stdio");
3770
4744
  }
3771
- var VERSION = "0.4.0";
4745
+ var VERSION = "0.5.0";
3772
4746
  main().catch((e) => {
3773
4747
  logger.error("fatal", e.message);
3774
4748
  process.exit(1);