transitions-refine 0.3.10 → 0.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,23 @@ export const DURATION_TOKENS = [
20
20
  // The transitions.dev default ease-out — "Smooth ease out" in the skill.
21
21
  export const SMOOTH_OUT = "cubic-bezier(0.22, 1, 0.36, 1)";
22
22
 
23
+ // Scales — from the skill's "## Motion tokens" Scales table. These are the
24
+ // non-resting ("pre") scale a surface animates FROM (it always settles to 1).
25
+ export const SCALE_TOKENS = [
26
+ { v: 0.96, name: "Large", usage: "modal open / close" },
27
+ { v: 0.97, name: "Medium", usage: "dropdown open" },
28
+ { v: 0.98, name: "Small", usage: "tooltip open" },
29
+ { v: 0.99, name: "Tiny", usage: "dropdown close" },
30
+ ];
31
+
32
+ // Blur (px) — from the skill's "## Motion tokens" Blur table. The non-resting
33
+ // blur a surface animates FROM (it always settles to 0).
34
+ export const BLUR_TOKENS = [
35
+ { px: 2, name: "Small", usage: "panel reveal, icon swap, text swap, skeleton reveal, number pop-in" },
36
+ { px: 3, name: "Medium", usage: "page slide, text reveal" },
37
+ { px: 8, name: "Large", usage: "success check open" },
38
+ ];
39
+
23
40
  // Easing values that ARE motion tokens — leave these alone.
24
41
  const TOKEN_EASINGS = new Set(
25
42
  [
@@ -49,6 +66,54 @@ function nearestDuration(ms) {
49
66
  return { token: best, delta: bestDelta };
50
67
  }
51
68
 
69
+ function nearestScale(v) {
70
+ let best = SCALE_TOKENS[0];
71
+ let bestDelta = Infinity;
72
+ for (const t of SCALE_TOKENS) {
73
+ const d = Math.abs(t.v - v);
74
+ if (d < bestDelta) { bestDelta = d; best = t; }
75
+ }
76
+ return { token: best, delta: bestDelta };
77
+ }
78
+
79
+ function nearestBlur(px) {
80
+ let best = BLUR_TOKENS[0];
81
+ let bestDelta = Infinity;
82
+ for (const t of BLUR_TOKENS) {
83
+ const d = Math.abs(t.px - px);
84
+ if (d < bestDelta) { bestDelta = d; best = t; }
85
+ }
86
+ return { token: best, delta: bestDelta };
87
+ }
88
+
89
+ const norm = (s) => String(s || "").toLowerCase();
90
+
91
+ // Usage-aware token pick — the skill says "match on USAGE, not the nearest
92
+ // number". Even deterministic should honour this when the label/selector/phase
93
+ // reveal the component, so a dropdown's 0.8 pre-scale snaps to 0.97 (dropdown
94
+ // open), not to 0.96 (the nearest number). Returns null when usage is unclear,
95
+ // and the caller falls back to nearest-by-magnitude.
96
+ function pickScaleByUsage(hint) {
97
+ const h = norm(hint);
98
+ if (!h) return null;
99
+ const isClose = /clos/.test(h);
100
+ if (/\bmodal\b|\bdialog\b|\blightbox\b/.test(h)) return SCALE_TOKENS.find((t) => t.v === 0.96);
101
+ if (/\btooltip\b|\btip\b|\bpopover\b/.test(h)) return SCALE_TOKENS.find((t) => t.v === 0.98);
102
+ if (/\bdropdown\b|drop-down|\bmenu\b|\bselect\b|\bcombobox\b|\bcaret\b/.test(h))
103
+ return SCALE_TOKENS.find((t) => t.v === (isClose ? 0.99 : 0.97));
104
+ return null;
105
+ }
106
+
107
+ function pickBlurByUsage(hint) {
108
+ const h = norm(hint);
109
+ if (!h) return null;
110
+ if (/success|check/.test(h)) return BLUR_TOKENS.find((t) => t.px === 8);
111
+ if (/\bpage\b|\bslide\b/.test(h)) return BLUR_TOKENS.find((t) => t.px === 3);
112
+ if (/text/.test(h) && /reveal/.test(h)) return BLUR_TOKENS.find((t) => t.px === 3);
113
+ if (/panel|icon|swap|skeleton|number|digit|reveal/.test(h)) return BLUR_TOKENS.find((t) => t.px === 2);
114
+ return null;
115
+ }
116
+
52
117
  // A generic/non-token easing the skill would nudge toward the default ease-out.
53
118
  function shouldRefineEasing(easing) {
54
119
  const n = normEase(easing);
@@ -60,15 +125,21 @@ function shouldRefineEasing(easing) {
60
125
 
61
126
  /**
62
127
  * Produce token-alignment suggestions for a list of property timings.
63
- * @param {{property:string,durationMs:number,delayMs:number,easing:string}[]} timings
128
+ * @param {{property:string,durationMs:number,delayMs:number,easing:string,scale?:number,blur?:number,varName?:string,hint?:string}[]} timings
129
+ * @param {{label?:string,selector?:string,phase?:string}} [ctx] usage hints for scale/blur token selection
64
130
  * @returns {object[]} suggestions
65
131
  */
66
- export function refineTimings(timings) {
132
+ export function refineTimings(timings, ctx) {
67
133
  const suggestions = [];
68
134
  if (!Array.isArray(timings)) return suggestions;
135
+ const c = ctx || {};
69
136
 
70
137
  for (const t of timings) {
71
138
  const prop = t.property || "all";
139
+ // Usage hint for scale/blur: combine the transition's label/selector/phase
140
+ // with this lane's own property/member so component words (dropdown, modal,
141
+ // page slide…) are visible to the usage matcher.
142
+ const hint = [c.label, c.selector, c.phase, t.hint, t.property, t.member].filter(Boolean).join(" ");
72
143
 
73
144
  // Duration → nearest token (skip if already on-grid or within 10ms).
74
145
  if (Number.isFinite(t.durationMs)) {
@@ -102,6 +173,49 @@ export function refineTimings(timings) {
102
173
  reason: `"${t.easing}" is a generic curve. The transitions.dev standard ease-out reads more intentional on opens, closes, slides, and resizes.`,
103
174
  });
104
175
  }
176
+
177
+ // Scale → a transitions.dev scale token (usage-first, nearest fallback). The
178
+ // captured value is the non-resting "pre" scale (settles to 1); skip 1/none.
179
+ if (Number.isFinite(t.scale) && Math.abs(t.scale - 1) > 1e-4) {
180
+ const usage = pickScaleByUsage(hint);
181
+ const { token: near, delta } = nearestScale(t.scale);
182
+ const token = usage || near;
183
+ // Suggest when usage names a token, or the current value is off-grid.
184
+ if (token && (usage || delta > 5e-3) && Math.abs(token.v - t.scale) > 1e-4) {
185
+ suggestions.push({
186
+ id: `${prop}-scale`,
187
+ kind: "scale",
188
+ property: prop,
189
+ member: t.member || null,
190
+ title: `Scale → ${token.name}`,
191
+ from: String(t.scale),
192
+ to: String(token.v),
193
+ patch: { property: prop, scale: token.v, ...(t.varName ? { varName: t.varName } : {}), ...(t.member ? { member: t.member } : {}) },
194
+ reason: `${token.name} (${token.v}) is the transitions.dev scale token for ${token.usage}. ${t.scale} is ${usage ? "off the usage token" : "off-grid"}.`,
195
+ });
196
+ }
197
+ }
198
+
199
+ // Blur → a transitions.dev blur token (usage-first, nearest fallback). The
200
+ // captured value is the non-resting "pre" blur (settles to 0); skip 0/none.
201
+ if (Number.isFinite(t.blur) && t.blur > 1e-4) {
202
+ const usage = pickBlurByUsage(hint);
203
+ const { token: near, delta } = nearestBlur(t.blur);
204
+ const token = usage || near;
205
+ if (token && (usage || delta > 0.5) && Math.abs(token.px - t.blur) > 1e-4) {
206
+ suggestions.push({
207
+ id: `${prop}-blur`,
208
+ kind: "blur",
209
+ property: prop,
210
+ member: t.member || null,
211
+ title: `Blur → ${token.name}`,
212
+ from: `${t.blur}px`,
213
+ to: `${token.px}px`,
214
+ patch: { property: prop, blur: token.px, ...(t.varName ? { varName: t.varName } : {}), ...(t.member ? { member: t.member } : {}) },
215
+ reason: `${token.name} (${token.px}px) is the transitions.dev blur token for ${token.usage}. ${t.blur}px is ${usage ? "off the usage token" : "off-grid"}.`,
216
+ });
217
+ }
218
+ }
105
219
  }
106
220
 
107
221
  return suggestions;
package/server/relay.mjs CHANGED
@@ -28,7 +28,7 @@ import { spawn } from "node:child_process";
28
28
  import { existsSync } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { delimiter, join } from "node:path";
31
- import { refineTimings, DURATION_TOKENS, SMOOTH_OUT } from "./motion-tokens.mjs";
31
+ import { refineTimings, DURATION_TOKENS, SCALE_TOKENS, BLUR_TOKENS, SMOOTH_OUT } from "./motion-tokens.mjs";
32
32
  import { buildInjectModule } from "./inject.mjs";
33
33
 
34
34
  const PORT = Number(process.env.REFINE_RELAY_PORT) || 7331;
@@ -86,6 +86,11 @@ const POLLER_TTL_MS = Number(process.env.REFINE_POLLER_TTL_MS) || 120000;
86
86
  // How long a pending LLM job waits to be claimed before erroring. Comfortably
87
87
  // above one long-poll cycle so a transient polling gap doesn't fail the job.
88
88
  const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 120000;
89
+ // Idle auto-stop for the in-chat `/refine live` loop. The chat poll loop costs
90
+ // agent turns even while idle, so after this long with no job we tell the loop
91
+ // to stop (it returns {stop:true} from /jobs/next). 0 disables. Only the chat
92
+ // loop is affected — a wired REFINE_AGENT_CMD never polls /jobs/next.
93
+ const POLLER_IDLE_STOP_MS = Number(process.env.REFINE_POLLER_IDLE_STOP_MS) || 600000;
89
94
 
90
95
  /** @type {Map<string, Job>} */
91
96
  const jobs = new Map();
@@ -97,6 +102,15 @@ let lastPollAt = 0;
97
102
  const pollerActive = () => now() - lastPollAt < POLLER_TTL_MS;
98
103
  const llmAvailable = () => Boolean(AGENT_CMD) || pollerActive();
99
104
 
105
+ // Stop signal for the in-chat `/refine live` loop. Set by POST /poller/stop
106
+ // (the panel's "Stop" button) or by the idle auto-stop; consumed by the next
107
+ // GET /jobs/next, which returns {stop:true} so the loop exits cleanly.
108
+ let stopRequested = false;
109
+ // When did a real job last arrive? Drives idle auto-stop so a forgotten loop
110
+ // can't poll (and bill) forever. Seeded on first poll so a fresh loop gets the
111
+ // full idle window before any auto-stop.
112
+ let lastJobAt = 0;
113
+
100
114
  // Whether the Cursor CLI (cursor-agent) is installed on this machine. Drives the
101
115
  // panel's two agent-unavailable states: "Cursor CLI not installed" vs. simply
102
116
  // "run /refine live". A wired REFINE_AGENT_CMD implies it's present.
@@ -131,6 +145,7 @@ function createJob(request) {
131
145
  updatedAt: now(),
132
146
  };
133
147
  jobs.set(id, job);
148
+ lastJobAt = now(); // real work → reset the chat-loop idle auto-stop window
134
149
  if (jobs.size > 100) {
135
150
  const oldest = [...jobs.values()].sort((a, b) => a.updatedAt - b.updatedAt)[0];
136
151
  if (oldest && oldest.status !== "pending" && oldest.status !== "working") jobs.delete(oldest.id);
@@ -162,6 +177,10 @@ const MOTION_TOKENS_BLOCK = [
162
177
  `Default easing "Smooth ease out": ${SMOOTH_OUT}.`,
163
178
  "Token easings (already on-grid — leave unchanged): ease-out, ease-in-out, linear, cubic-bezier(0.34, 1.36, 0.64, 1) (bouncy overshoot, badge pop), cubic-bezier(0.34, 3.85, 0.64, 1) (strong overshoot, avatar return).",
164
179
  'Generic curves to nudge toward "Smooth ease out": "ease", "ease-in", or any hand-rolled cubic-bezier()/linear() that is not a token above.',
180
+ "Scales (the non-resting 'pre' scale a surface animates FROM — it always settles to 1):",
181
+ ...SCALE_TOKENS.map((t) => ` - ${t.v} ${t.name}: ${t.usage}`),
182
+ "Blur (the non-resting 'pre' blur a surface animates FROM — it always settles to 0):",
183
+ ...BLUR_TOKENS.map((t) => ` - ${t.px}px ${t.name}: ${t.usage}`),
165
184
  ].join("\n");
166
185
 
167
186
  // Inlined recipe catalog + decision hints (mirrors the transitions-dev skill's
@@ -199,48 +218,70 @@ function buildPrompt(job) {
199
218
  const rawType = r.refineType || "small";
200
219
  const refineType = rawType === "replace" ? "replace" : rawType === "both" ? "both" : "small";
201
220
  const needsRecipe = refineType === "replace" || refineType === "both";
221
+ // A grouped transition usually has related phases (open + close). When the panel
222
+ // sends them, a recipe swap is ONE motion and must update every phase together —
223
+ // so the agent returns a `patches` array (one entry per phase) instead of a
224
+ // single-phase `patch`.
225
+ const phases = Array.isArray(r.phases) ? r.phases.filter((p) => p && p.phase) : [];
226
+ const multiPhase = needsRecipe && phases.length > 1;
202
227
  const lines = [
203
228
  "You are refining ONE CSS transition against the transitions.dev library and motion tokens.",
204
229
  MOTION_TOKENS_BLOCK,
205
230
  "",
206
231
  "Transition context (JSON):",
207
- JSON.stringify({ label: r.label, selector: r.selector, refineType, timings: r.timings }, null, 2),
232
+ JSON.stringify({ label: r.label, selector: r.selector, refineType, timings: r.timings, phases: phases.length ? phases : undefined }, null, 2),
208
233
  "",
209
234
  "Infer each declaration's USAGE (modal close, dropdown open, tooltip, badge, resize, color/theme change…) from the label/selector. Match on usage intent, not the nearest number.",
210
235
  "",
236
+ "Some timings carry VALUES too: a `transform` lane may include `scale` (its non-resting pre-scale, e.g. 0.8) and a `filter` lane may include `blur` (its non-resting pre-blur in px). When present, also check these against the Scales/Blur tokens by USAGE (a dropdown-open surface should pre-scale to 0.97, not whatever number it has) and propose a fix when they differ. A lane may also carry `varName` (the CSS custom property backing the value) — pass it straight through in the patch so the edit targets that variable.",
237
+ "",
211
238
  ];
212
239
  if (needsRecipe) {
213
240
  lines.push(
214
- "To pick a whole-transition replacement, match the inferred USAGE against the recipe list below (the skill's decision rules are summarised here — do NOT read SKILL.md or any reference file). Derive the `patch`'s duration/easing from the MOTION TOKENS above for the recipe's phase (open vs close), and put the recipe's reference filename in `reference` so the user can paste the full recipe themselves. The patch only drives the live preview; exact keyframes/structure come from that pasted file — so you never need to open it.",
241
+ "To pick a whole-transition replacement, match the inferred USAGE against the recipe list below (the skill's decision rules are summarised here — do NOT read SKILL.md or any reference file). Derive the duration/easing from the MOTION TOKENS above for the recipe's phase (open vs close), and put the recipe's reference filename in `reference` so the user can paste the full recipe themselves. The patch only drives the live preview; exact keyframes/structure come from that pasted file — so you never need to open it.",
215
242
  "",
216
243
  RECIPES_BLOCK,
217
244
  "",
218
245
  );
246
+ if (multiPhase) {
247
+ lines.push(
248
+ "RELATED PHASES: `phases` lists this transition's related states (e.g. open AND close). They are ONE motion — a recipe swap must update them TOGETHER. For the replace suggestion emit a `patches` array with ONE entry per phase in `phases`, each shaped `{\"phase\":<that phase's name verbatim>,\"property\":\"all\",\"durationMs\":<recipe duration for THAT phase>,\"easing\":<recipe easing>,\"scale\":<recipe pre-scale for THAT phase>,\"blur\":<recipe pre-blur px for THAT phase>}`. Take each phase's duration from the MOTION TOKENS for that phase — open is usually slower than close (e.g. 250ms open / 150ms close) — and use the same easing for both unless the recipe differs. Include `scale`/`blur` ONLY when the recipe animates transform-scale / filter-blur (omit them otherwise), using the Scales/Blur tokens for that phase. Also include a single `patch` equal to the FIRST phase's entry (it drives the live preview). Apply will write every phase from `patches`.",
249
+ "",
250
+ );
251
+ }
219
252
  }
220
253
  if (refineType === "replace") {
221
254
  lines.push(
222
255
  "refineType is \"replace\": suggest a WHOLE-TRANSITION replacement ONLY — do NOT propose motion-token tweaks (no kind \"duration\"/\"delay\"/\"easing\").",
223
- "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\") so Apply works live, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits the usage, return an empty suggestions array.",
256
+ multiPhase
257
+ ? "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": include the `patches` array (one entry per related phase, as described above) AND a single `patch` (the first phase) for the live preview, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits the usage, return an empty suggestions array."
258
+ : "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\") so Apply works live, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits the usage, return an empty suggestions array.",
224
259
  "Answer in ONE response — do NOT read or search files.",
225
260
  );
226
261
  } else if (refineType === "both") {
227
262
  lines.push(
228
263
  "refineType is \"both\": produce TWO independent groups in the SAME suggestions array — the UI shows them in separate tabs, so include each group whenever it applies.",
229
- "(1) Motion-token tweaks (kind \"duration\"/\"delay\"/\"easing\"): for each declaration, propose the token value only where it DIFFERS from the current one.",
230
- "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe from the list above. Emit at most ONE \"replace\" suggestion: set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\"), add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion.",
264
+ "(1) Motion-token tweaks (kind \"duration\"/\"delay\"/\"easing\"/\"scale\"/\"blur\"): for each declaration, propose the token value only where it DIFFERS from the current one. Use \"scale\" for an off-token transform pre-scale and \"blur\" for an off-token filter pre-blur, picked by usage.",
265
+ multiPhase
266
+ ? "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe. Emit at most ONE \"replace\" suggestion with the `patches` array (one entry per related phase) AND a single `patch` (the first phase), a `reference` field, and the recipe named in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion."
267
+ : "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe from the list above. Emit at most ONE \"replace\" suggestion: set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\"), add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion.",
231
268
  "Answer in ONE response — do NOT read or search files.",
232
269
  );
233
270
  } else {
234
271
  lines.push(
235
- "refineType is \"small\": suggest motion-token tweaks ONLY — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\"). Do NOT propose a whole-transition replacement (no kind \"replace\") — the Replace tab requests that separately.",
272
+ "refineType is \"small\": suggest motion-token tweaks ONLY — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\"/\"scale\"/\"blur\"). Use \"scale\" for an off-token transform pre-scale and \"blur\" for an off-token filter pre-blur, picked by usage. Do NOT propose a whole-transition replacement (no kind \"replace\") — the Replace tab requests that separately.",
236
273
  "Answer in ONE response using ONLY the data above. Do NOT read or search files, run tools, spawn subagents, or explore the codebase — the motion tokens contain everything you need. This is a quick judgement, not a coding task.",
237
274
  );
238
275
  }
239
276
  lines.push(
240
277
  "",
241
278
  "Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
242
- '{"summary":"…","suggestions":[{"id":"width-duration","kind":"duration","property":"width","title":"Duration → Fast","from":"400ms","to":"250ms","patch":{"property":"width","durationMs":250},"reason":"…"}]}',
243
- 'In each `patch` include only the changed fields (durationMs, delayMs, easing); `property` must match an input property or "all". If nothing should change, return an empty suggestions array.',
279
+ '{"summary":"…","suggestions":[{"id":"width-duration","kind":"duration","property":"width","member":"Container","title":"Duration → Fast","from":"400ms","to":"250ms","patch":{"property":"width","member":"Container","durationMs":250},"reason":"…"}]}',
280
+ 'A scale tweak looks like {"id":"transform-scale","kind":"scale","property":"transform","member":"Menu","title":"Scale Medium","from":"0.8","to":"0.97","patch":{"property":"transform","member":"Menu","scale":0.97},"reason":"…"}; a blur tweak like {"id":"filter-blur","kind":"blur","property":"filter","member":"Panel","title":"Blur Small","from":"8px","to":"2px","patch":{"property":"filter","member":"Panel","blur":2},"reason":"…"}.',
281
+ "CRITICAL — `member`: every suggestion and its `patch` MUST echo the `member` of the input lane it came from, copied VERBATIM from that lane in the data above. This is REQUIRED whenever any input lane carries a `member` — most importantly when several lanes share the same `property` (e.g. a dropdown where a caret does `transform: rotate` AND a panel does `transform: scale`): the `member` is the ONLY way to tell which lane a `transform` tweak targets. Omitting it mislabels the suggestion onto the wrong element. Omit `member` only for lanes that genuinely have none.",
282
+ multiPhase
283
+ ? 'A multi-phase replace suggestion ALSO carries a `patches` array, e.g. "patches":[{"phase":"open","property":"all","durationMs":250,"easing":"cubic-bezier(0.22, 1, 0.36, 1)","scale":0.97},{"phase":"close","property":"all","durationMs":150,"easing":"cubic-bezier(0.22, 1, 0.36, 1)","scale":0.99}]. In each `patch`/`patches` entry include only the changed fields (durationMs, delayMs, easing, scale, blur — plus `member` copied from the input lane, and `varName` if the input lane had one); `property` must match an input property or "all".'
284
+ : 'In each `patch` include only the changed fields (durationMs, delayMs, easing, scale, blur — plus `member` copied from the input lane, and `varName` if the input lane had one); `property` must match an input property or "all". If nothing should change, return an empty suggestions array.',
244
285
  );
245
286
  return lines.join("\n");
246
287
  }
@@ -281,7 +322,31 @@ function parseScanOutput(stdout) {
281
322
  return { groups: obj.groups, summary: obj.summary ?? null };
282
323
  }
283
324
 
284
- function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
325
+ // Serialize cursor-agent spawns. Two cursor-agent processes started at once race
326
+ // on their shared CLI config — one renames `…/cli-config.json.tmp → cli-config.json`
327
+ // while the other already moved it, so the loser crashes with ENOENT mid config
328
+ // save and its scan/refine job fails. Because the relay fires every job via
329
+ // setImmediate, a cold-start scan + any other job spawn in parallel and trip this.
330
+ // Running cursor-agent one-at-a-time removes the race; jobs are rarely concurrent
331
+ // so the queuing cost is negligible (and each attempt is still bounded by
332
+ // AGENT_TIMEOUT_MS). Non-cursor CLIs are left to run freely.
333
+ let agentLock = Promise.resolve();
334
+ function withAgentLock(fn) {
335
+ const run = agentLock.then(fn, fn);
336
+ // keep the chain alive regardless of this run's outcome
337
+ agentLock = run.then(() => {}, () => {});
338
+ return run;
339
+ }
340
+
341
+ // Failures worth retrying: transient agent startup / shared-config races, not
342
+ // genuine "the model answered wrong" errors (those reject from parse, not here)
343
+ // or timeouts (a retry would just time out again).
344
+ const AGENT_TRANSIENT_RE = /ENOENT|EAGAIN|EBUSY|ECONNRESET|EPIPE|cli-config|\brename\b|failed to start/i;
345
+ const AGENT_RETRIES = Number.isFinite(Number(process.env.REFINE_AGENT_RETRIES))
346
+ ? Number(process.env.REFINE_AGENT_RETRIES)
347
+ : 2;
348
+
349
+ function runAgentOnce(cmd, prompt, parse) {
285
350
  return new Promise((resolve, reject) => {
286
351
  const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
287
352
  let out = "";
@@ -310,6 +375,34 @@ function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
310
375
  });
311
376
  }
312
377
 
378
+ // Spawn the agent CLI once per attempt, serializing cursor-agent runs and
379
+ // retrying transient startup/config-race failures with backoff. A retry re-spawns
380
+ // a fresh process (read-only scan/refine, idempotent apply), so it's safe — and
381
+ // the backoff happens OUTSIDE the lock so a retrying job doesn't block others.
382
+ async function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
383
+ const isCursor = /(^|\s|\/)cursor-agent(\s|$)/.test(cmd || "");
384
+ const attempt = () =>
385
+ isCursor ? withAgentLock(() => runAgentOnce(cmd, prompt, parse)) : runAgentOnce(cmd, prompt, parse);
386
+ const maxTries = 1 + Math.max(0, AGENT_RETRIES);
387
+ let lastErr;
388
+ for (let i = 0; i < maxTries; i++) {
389
+ try {
390
+ return await attempt();
391
+ } catch (e) {
392
+ lastErr = e;
393
+ const msg = String((e && e.message) || e);
394
+ if (i < maxTries - 1 && AGENT_TRANSIENT_RE.test(msg)) {
395
+ const backoff = 250 * (i + 1) + Math.floor(Math.random() * 150);
396
+ console.warn(` ↻ agent attempt ${i + 1}/${maxTries} failed (${msg.slice(0, 120)}) — retrying in ${backoff}ms`);
397
+ await new Promise((r) => setTimeout(r, backoff));
398
+ continue;
399
+ }
400
+ throw e;
401
+ }
402
+ }
403
+ throw lastErr;
404
+ }
405
+
313
406
  // Prompt for an "apply" job: the agent edits the user's source so the selected
314
407
  // transition uses the approved timings.
315
408
  function buildApplyPrompt(job) {
@@ -320,11 +413,11 @@ function buildApplyPrompt(job) {
320
413
  "Change context (JSON):",
321
414
  JSON.stringify({ label: r.label, selector: r.selector, component: r.component, group: r.group, phase: r.phase, changes: r.changes }, null, 2),
322
415
  "",
323
- "If `phase` is set (e.g. \"open\"/\"close\"), the change targets ONE state of a component — edit the rule for THAT state (e.g. the `.is-open` rule for open, the `.is-closing`/base rule for close), not the other phase. Each change may carry its own `member` + `selector` identifying which element it belongs to.",
416
+ "Each change may carry its own `phase` (e.g. \"open\"/\"close\"), `member`, and `selector`. A change's `phase` targets ONE state of the component — edit the rule for THAT state (e.g. the `.is-open` rule for open; the `.is-closing`/base rule for close), NOT the other phase. The top-level `phase` is only a fallback for changes that omit their own. A recipe swap may include changes for BOTH open and close — apply each to its own phase's rule so the open and close timings end up different where the recipe says so.",
324
417
  "",
325
418
  "Steps:",
326
419
  "1. Find where this transition is defined in the source. Search by the per-change `selector`/`member`, the `component` hint, and class names. Handle plain CSS, CSS Modules, styled-components/emotion template literals, Tailwind utilities/config, inline style objects, and Motion/Framer variants — the browser selector is a hint, the real declaration may live in any of these.",
327
- "2. For each change, edit the source so that property's transition uses the `to` values: durationMs (ms), easing, delayMs (ms). Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the timing of the named property on the right member + phase. If a CSS variable / design token backs the value, update it at the most sensible single place.",
420
+ "2. For each change, edit the source so that property's transition uses the `to` values. A change's `to` may include timing (durationMs in ms, easing, delayMs in ms) AND/OR values: `scale` (the non-resting transform pre-scale, e.g. set `transform: scale(0.97)` on the pre-open/closed state — never the resting scale(1)) and `blur` (the non-resting filter pre-blur in px, e.g. `filter: blur(2px)` on that same state). If the change carries `varName`, the value is backed by that CSS custom property update the variable's value at its definition instead of the inline declaration. Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the named property on the right member + phase.",
328
421
  "3. Make the minimal edit. Do not reformat or change unrelated code.",
329
422
  "",
330
423
  'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
@@ -369,7 +462,8 @@ function refineDeterministic(job) {
369
462
  summary: "Replacing a whole transition needs the agent — switch to the Agent tab and run `/refine live`.",
370
463
  };
371
464
  }
372
- const suggestions = refineTimings(job.request?.timings || []);
465
+ const r = job.request || {};
466
+ const suggestions = refineTimings(r.timings || [], { label: r.label, selector: r.selector, phase: r.phase });
373
467
  return {
374
468
  suggestions,
375
469
  summary: suggestions.length
@@ -560,11 +654,27 @@ const server = createServer(async (req, res) => {
560
654
  // GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
561
655
  if (method === "GET" && path === "/jobs/next") {
562
656
  lastPollAt = now();
657
+ if (!lastJobAt) lastJobAt = now(); // seed idle window on the loop's first poll
658
+ // Stop signal (manual Stop button or idle auto-stop) → tell the loop to exit.
659
+ // A pending job always wins so we never drop real work on the stop edge.
660
+ if ((stopRequested || (POLLER_IDLE_STOP_MS && now() - lastJobAt >= POLLER_IDLE_STOP_MS)) && !nextPendingLlm()) {
661
+ stopRequested = false;
662
+ lastJobAt = 0;
663
+ lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
664
+ return send(res, 200, { stop: true });
665
+ }
563
666
  const deadline = now() + LONGPOLL_MS;
564
667
  const attempt = () => {
565
668
  if (res.writableEnded) return;
669
+ if (stopRequested && !nextPendingLlm()) {
670
+ stopRequested = false;
671
+ lastJobAt = 0;
672
+ lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
673
+ return send(res, 200, { stop: true });
674
+ }
566
675
  const job = nextPendingLlm();
567
676
  if (job) {
677
+ lastJobAt = now();
568
678
  job.status = "working";
569
679
  job.updatedAt = now();
570
680
  return send(res, 200, { id: job.id, request: job.request });
@@ -575,6 +685,13 @@ const server = createServer(async (req, res) => {
575
685
  return attempt();
576
686
  }
577
687
 
688
+ // POST /poller/stop — the panel's "Stop" button. Flags the in-chat loop to
689
+ // exit on its next poll. No-op for a wired REFINE_AGENT_CMD (never polls).
690
+ if (method === "POST" && path === "/poller/stop") {
691
+ stopRequested = true;
692
+ return send(res, 200, { ok: true, stopping: pollerActive() });
693
+ }
694
+
578
695
  const m = path.match(/^\/jobs\/([^/]+)(?:\/(status|result|error))?$/);
579
696
  if (m) {
580
697
  const job = jobs.get(m[1]);