transitions-refine 0.1.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/.agents/skills/refine-live/SKILL.md +205 -0
- package/.agents/skills/transitions-dev/01-card-resize.md +53 -0
- package/.agents/skills/transitions-dev/02-number-pop-in.md +119 -0
- package/.agents/skills/transitions-dev/03-notification-badge.md +110 -0
- package/.agents/skills/transitions-dev/04-text-states-swap.md +97 -0
- package/.agents/skills/transitions-dev/05-menu-dropdown.md +105 -0
- package/.agents/skills/transitions-dev/06-modal.md +94 -0
- package/.agents/skills/transitions-dev/07-panel-reveal.md +81 -0
- package/.agents/skills/transitions-dev/08-page-side-by-side.md +100 -0
- package/.agents/skills/transitions-dev/09-icon-swap.md +78 -0
- package/.agents/skills/transitions-dev/10-success-check.md +169 -0
- package/.agents/skills/transitions-dev/11-avatar-group-hover.md +200 -0
- package/.agents/skills/transitions-dev/12-error-state-shake.md +202 -0
- package/.agents/skills/transitions-dev/13-input-clear-dissolve.md +276 -0
- package/.agents/skills/transitions-dev/14-skeleton-reveal.md +149 -0
- package/.agents/skills/transitions-dev/15-shimmer-text.md +95 -0
- package/.agents/skills/transitions-dev/16-tabs-sliding.md +146 -0
- package/.agents/skills/transitions-dev/17-tooltip.md +103 -0
- package/.agents/skills/transitions-dev/18-texts-reveal.md +110 -0
- package/.agents/skills/transitions-dev/19-card-tilt.md +170 -0
- package/.agents/skills/transitions-dev/20-plus-menu-morph.md +167 -0
- package/.agents/skills/transitions-dev/21-accordion.md +124 -0
- package/.agents/skills/transitions-dev/SKILL.md +225 -0
- package/.agents/skills/transitions-dev/_root.css +204 -0
- package/README.md +89 -0
- package/bin/cli.mjs +264 -0
- package/demo.html +2531 -0
- package/package.json +37 -0
- package/server/inject.mjs +116 -0
- package/server/motion-tokens.mjs +106 -0
- package/server/refine-agent.mjs +86 -0
- package/server/relay.mjs +421 -0
package/server/relay.mjs
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Refine relay — local server behind the timeline panel's Refine button.
|
|
2
|
+
//
|
|
3
|
+
// It does two jobs:
|
|
4
|
+
// 1. Serves the injectable timeline at GET /inject.js, so any page can show
|
|
5
|
+
// the panel with a single <script> tag (no npm install — see bin/cli.mjs).
|
|
6
|
+
// 2. Brokers refine jobs between the browser and whoever answers them.
|
|
7
|
+
//
|
|
8
|
+
// Browser ──POST /jobs──► relay ──► answer ──► Browser ──GET /jobs/:id──►
|
|
9
|
+
//
|
|
10
|
+
// Who answers a job (chosen per request via the panel's LLM / Deterministic tabs):
|
|
11
|
+
// • Deterministic → answered in-process by snapping each value to the nearest
|
|
12
|
+
// transitions.dev motion token (zero-config, not usage-aware).
|
|
13
|
+
// • LLM → answered by a real agent. Two ways to provide one:
|
|
14
|
+
// a) A polling agent in your editor: run `/refine live` in Cursor/Codex.
|
|
15
|
+
// It long-polls GET /jobs/next, reasons with the transitions-dev skill,
|
|
16
|
+
// and POSTs the result back. This is the default, install-free path.
|
|
17
|
+
// b) A headless CLI: start the relay with REFINE_AGENT_CMD set and the
|
|
18
|
+
// relay spawns it once per job (stdin = prompt, stdout = JSON).
|
|
19
|
+
// e.g. REFINE_AGENT_CMD='cursor-agent -p' npm run relay
|
|
20
|
+
//
|
|
21
|
+
// Run: node server/relay.mjs (or: npm run relay)
|
|
22
|
+
|
|
23
|
+
import { createServer } from "node:http";
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { delimiter, join } from "node:path";
|
|
29
|
+
import { refineTimings } from "./motion-tokens.mjs";
|
|
30
|
+
import { buildInjectModule } from "./inject.mjs";
|
|
31
|
+
|
|
32
|
+
const PORT = Number(process.env.REFINE_RELAY_PORT) || 7331;
|
|
33
|
+
const AUTO = process.env.REFINE_AUTO !== "0";
|
|
34
|
+
const AGENT_CMD = process.env.REFINE_AGENT_CMD || null;
|
|
35
|
+
const AGENT_TIMEOUT_MS = Number(process.env.REFINE_AGENT_TIMEOUT_MS) || 120000;
|
|
36
|
+
const LONGPOLL_MS = Number(process.env.REFINE_LONGPOLL_MS) || 25000;
|
|
37
|
+
// Grace window after a `/refine live` agent's last poll during which LLM mode is
|
|
38
|
+
// still reported "available". Kept well above LONGPOLL_MS so the normal gaps
|
|
39
|
+
// between an in-IDE agent's polls (reasoning, posting results, brief turn pauses)
|
|
40
|
+
// don't flip the panel's LLM tab off mid-session.
|
|
41
|
+
const POLLER_TTL_MS = Number(process.env.REFINE_POLLER_TTL_MS) || 120000;
|
|
42
|
+
// How long a pending LLM job waits to be claimed before erroring. Comfortably
|
|
43
|
+
// above one long-poll cycle so a transient polling gap doesn't fail the job.
|
|
44
|
+
const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 120000;
|
|
45
|
+
|
|
46
|
+
/** @type {Map<string, Job>} */
|
|
47
|
+
const jobs = new Map();
|
|
48
|
+
const now = () => Date.now();
|
|
49
|
+
|
|
50
|
+
// When did a `/refine live` agent last poll? Used to know if LLM mode can be
|
|
51
|
+
// served by a live editor agent (vs. needing REFINE_AGENT_CMD).
|
|
52
|
+
let lastPollAt = 0;
|
|
53
|
+
const pollerActive = () => now() - lastPollAt < POLLER_TTL_MS;
|
|
54
|
+
const llmAvailable = () => Boolean(AGENT_CMD) || pollerActive();
|
|
55
|
+
|
|
56
|
+
// Whether the Cursor CLI (cursor-agent) is installed on this machine. Drives the
|
|
57
|
+
// panel's two agent-unavailable states: "Cursor CLI not installed" vs. simply
|
|
58
|
+
// "run /refine live". A wired REFINE_AGENT_CMD implies it's present.
|
|
59
|
+
const HOME = homedir();
|
|
60
|
+
const AGENT_BIN_CANDIDATES = [
|
|
61
|
+
join(HOME, ".local", "bin", "cursor-agent"),
|
|
62
|
+
"/usr/local/bin/cursor-agent",
|
|
63
|
+
"/opt/homebrew/bin/cursor-agent",
|
|
64
|
+
];
|
|
65
|
+
function cursorCliInstalled() {
|
|
66
|
+
if (AGENT_CMD) return true;
|
|
67
|
+
for (const p of AGENT_BIN_CANDIDATES) {
|
|
68
|
+
try { if (existsSync(p)) return true; } catch {}
|
|
69
|
+
}
|
|
70
|
+
for (const dir of (process.env.PATH || "").split(delimiter)) {
|
|
71
|
+
if (!dir) continue;
|
|
72
|
+
try { if (existsSync(join(dir, "cursor-agent"))) return true; } catch {}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createJob(request) {
|
|
78
|
+
const id = randomUUID();
|
|
79
|
+
const job = {
|
|
80
|
+
id,
|
|
81
|
+
status: "pending", // pending | working | done | error
|
|
82
|
+
request,
|
|
83
|
+
statusLog: [],
|
|
84
|
+
result: null,
|
|
85
|
+
error: null,
|
|
86
|
+
createdAt: now(),
|
|
87
|
+
updatedAt: now(),
|
|
88
|
+
};
|
|
89
|
+
jobs.set(id, job);
|
|
90
|
+
if (jobs.size > 100) {
|
|
91
|
+
const oldest = [...jobs.values()].sort((a, b) => a.updatedAt - b.updatedAt)[0];
|
|
92
|
+
if (oldest && oldest.status !== "pending" && oldest.status !== "working") jobs.delete(oldest.id);
|
|
93
|
+
}
|
|
94
|
+
return job;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// LLM jobs left pending for a `/refine live` agent to claim via GET /jobs/next.
|
|
98
|
+
function nextPendingLlm() {
|
|
99
|
+
let oldest = null;
|
|
100
|
+
for (const job of jobs.values()) {
|
|
101
|
+
if (job.status !== "pending") continue;
|
|
102
|
+
if ((job.request?.mode || "llm") !== "llm") continue;
|
|
103
|
+
if (!oldest || job.createdAt < oldest.createdAt) oldest = job;
|
|
104
|
+
}
|
|
105
|
+
return oldest;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── answering a job (one run per job) ────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function buildPrompt(job) {
|
|
111
|
+
const r = job.request || {};
|
|
112
|
+
const refineType = (r.refineType || "small") === "replace" ? "replace" : "small";
|
|
113
|
+
const lines = [
|
|
114
|
+
"You are refining ONE CSS transition against the transitions.dev library and motion tokens.",
|
|
115
|
+
"Read the transitions-dev skill's SKILL.md (look in .agents/skills/transitions-dev/ or ~/.agents/skills/transitions-dev/) and apply its `transitions refine` behaviour, `## Motion tokens`, and `## Decision rules`.",
|
|
116
|
+
"",
|
|
117
|
+
"Transition context (JSON):",
|
|
118
|
+
JSON.stringify({ label: r.label, selector: r.selector, refineType, timings: r.timings }, null, 2),
|
|
119
|
+
"",
|
|
120
|
+
"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.",
|
|
121
|
+
"",
|
|
122
|
+
];
|
|
123
|
+
if (refineType === "replace") {
|
|
124
|
+
lines.push(
|
|
125
|
+
"refineType is \"replace\": suggest a WHOLE-TRANSITION replacement ONLY — do NOT propose motion-token tweaks (no kind \"duration\"/\"delay\"/\"easing\").",
|
|
126
|
+
"Run the skill's `## Decision rules` on the inferred usage, pick the SINGLE best-fit transitions.dev recipe, and read its reference file (e.g. 06-modal.md) for the real timings/easing. Emit ONE suggestion with kind \"replace\": set its `patch` to the recipe's recommended duration/easing for 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`. Never invent timings — quote the reference file. If no recipe genuinely fits the usage, return an empty suggestions array.",
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
lines.push(
|
|
130
|
+
"refineType is \"small\": FIRST suggest motion-token tweaks — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\").",
|
|
131
|
+
"THEN, when it is possible and sensible, ALSO add at most ONE kind \"replace\" suggestion (alongside, not instead of, the token tweaks): run the skill's `## Decision rules`, pick the SINGLE best-fit recipe, read its reference file for the real timings, set its `patch` to the recipe's recommended duration/easing for the existing property (or \"all\"), add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. Only add it when the transition is clearly a hand-rolled version of a catalogued recipe or is missing structure the usage calls for; otherwise omit it and let the token tweaks stand alone.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
lines.push(
|
|
135
|
+
"",
|
|
136
|
+
"Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
|
|
137
|
+
'{"summary":"…","suggestions":[{"id":"width-duration","kind":"duration","property":"width","title":"Duration → Fast","from":"400ms","to":"250ms","patch":{"property":"width","durationMs":250},"reason":"…"}]}',
|
|
138
|
+
'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.',
|
|
139
|
+
);
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseAgentOutput(stdout) {
|
|
144
|
+
let s = (stdout || "").trim();
|
|
145
|
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
146
|
+
if (fence) s = fence[1].trim();
|
|
147
|
+
let obj;
|
|
148
|
+
try {
|
|
149
|
+
obj = JSON.parse(s);
|
|
150
|
+
} catch {
|
|
151
|
+
const a = s.indexOf("{");
|
|
152
|
+
const b = s.lastIndexOf("}");
|
|
153
|
+
if (a >= 0 && b > a) obj = JSON.parse(s.slice(a, b + 1));
|
|
154
|
+
else throw new Error("agent output was not JSON");
|
|
155
|
+
}
|
|
156
|
+
if (!obj || !Array.isArray(obj.suggestions)) throw new Error("agent output missing suggestions[]");
|
|
157
|
+
return { suggestions: obj.suggestions, summary: obj.summary ?? null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function runAgentCmd(cmd, prompt) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
163
|
+
let out = "";
|
|
164
|
+
let err = "";
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
child.kill("SIGKILL");
|
|
167
|
+
reject(new Error(`REFINE_AGENT_CMD timed out after ${AGENT_TIMEOUT_MS}ms`));
|
|
168
|
+
}, AGENT_TIMEOUT_MS);
|
|
169
|
+
child.stdout.on("data", (d) => (out += d));
|
|
170
|
+
child.stderr.on("data", (d) => (err += d));
|
|
171
|
+
child.on("error", (e) => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
reject(new Error(`failed to start REFINE_AGENT_CMD: ${e.message}`));
|
|
174
|
+
});
|
|
175
|
+
child.on("close", (code) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (code !== 0) return reject(new Error(`agent exited ${code}: ${err.slice(0, 300)}`));
|
|
178
|
+
try {
|
|
179
|
+
resolve(parseAgentOutput(out));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
reject(new Error(`${e.message} — got: ${out.slice(0, 200)}`));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
child.stdin.write(prompt);
|
|
185
|
+
child.stdin.end();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function refineDeterministic(job) {
|
|
190
|
+
// Whole-transition replacement needs usage inference + recipe selection, which
|
|
191
|
+
// only the agent (LLM) path can do. Deterministic can only snap to tokens.
|
|
192
|
+
if ((job.request?.refineType || "small") === "replace") {
|
|
193
|
+
return {
|
|
194
|
+
suggestions: [],
|
|
195
|
+
summary: "Replacing a whole transition needs the agent — switch to the Agent tab and run `/refine live`.",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const suggestions = refineTimings(job.request?.timings || []);
|
|
199
|
+
return {
|
|
200
|
+
suggestions,
|
|
201
|
+
summary: suggestions.length
|
|
202
|
+
? `${suggestions.length} value${suggestions.length === 1 ? "" : "s"} differ from the transitions.dev tokens.`
|
|
203
|
+
: "Already aligned to the motion tokens.",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function answerJob(job) {
|
|
208
|
+
job.status = "working";
|
|
209
|
+
job.updatedAt = now();
|
|
210
|
+
const label = job.request?.label || job.request?.selector || "transition";
|
|
211
|
+
// The browser picks the mode per job via the LLM / Deterministic tabs.
|
|
212
|
+
// Default: LLM when a command is configured, otherwise deterministic.
|
|
213
|
+
const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
|
|
214
|
+
job.statusLog.push({ message: `Scanning "${label}"…`, at: now() });
|
|
215
|
+
try {
|
|
216
|
+
let result;
|
|
217
|
+
if (mode === "llm") {
|
|
218
|
+
if (!AGENT_CMD) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
"LLM mode needs an agent CLI. Restart the relay with REFINE_AGENT_CMD set " +
|
|
221
|
+
"(e.g. REFINE_AGENT_CMD='cursor-agent -p' npm run relay), or switch to the Deterministic tab."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
job.statusLog.push({ message: "Asking your agent…", at: now() });
|
|
225
|
+
result = await runAgentCmd(AGENT_CMD, buildPrompt(job));
|
|
226
|
+
} else {
|
|
227
|
+
job.statusLog.push({ message: "Matching values to the motion tokens…", at: now() });
|
|
228
|
+
result = refineDeterministic(job);
|
|
229
|
+
}
|
|
230
|
+
job.result = { suggestions: result.suggestions, summary: result.summary };
|
|
231
|
+
job.status = "done";
|
|
232
|
+
job.updatedAt = now();
|
|
233
|
+
console.log(` ✓ job ${job.id.slice(0, 8)} — ${result.suggestions.length} suggestion(s)`);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
job.error = String(e.message || e);
|
|
236
|
+
job.status = "error";
|
|
237
|
+
job.updatedAt = now();
|
|
238
|
+
console.error(` ✗ job ${job.id.slice(0, 8)} — ${job.error}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── http plumbing ────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function send(res, status, body) {
|
|
245
|
+
const payload = body === undefined ? "" : JSON.stringify(body);
|
|
246
|
+
res.writeHead(status, {
|
|
247
|
+
"Content-Type": "application/json",
|
|
248
|
+
"Access-Control-Allow-Origin": "*",
|
|
249
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
250
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
251
|
+
"Cache-Control": "no-store",
|
|
252
|
+
});
|
|
253
|
+
res.end(payload);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readJson(req) {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
let raw = "";
|
|
259
|
+
req.on("data", (c) => {
|
|
260
|
+
raw += c;
|
|
261
|
+
if (raw.length > 1_000_000) req.destroy();
|
|
262
|
+
});
|
|
263
|
+
req.on("end", () => {
|
|
264
|
+
if (!raw) return resolve({});
|
|
265
|
+
try {
|
|
266
|
+
resolve(JSON.parse(raw));
|
|
267
|
+
} catch {
|
|
268
|
+
resolve(null);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
req.on("error", () => resolve(null));
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const server = createServer(async (req, res) => {
|
|
276
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
277
|
+
const path = url.pathname;
|
|
278
|
+
const method = req.method || "GET";
|
|
279
|
+
|
|
280
|
+
if (method === "OPTIONS") return send(res, 204);
|
|
281
|
+
|
|
282
|
+
if (method === "GET" && path === "/health") {
|
|
283
|
+
return send(res, 200, {
|
|
284
|
+
ok: true,
|
|
285
|
+
auto: AUTO,
|
|
286
|
+
llmAvailable: llmAvailable(),
|
|
287
|
+
pollerActive: pollerActive(),
|
|
288
|
+
agentCmd: Boolean(AGENT_CMD),
|
|
289
|
+
cliInstalled: cursorCliInstalled(),
|
|
290
|
+
jobs: jobs.size,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// GET /inject.js — the self-mounting timeline, served to any page.
|
|
295
|
+
if (method === "GET" && (path === "/inject.js" || path === "/timeline.mjs")) {
|
|
296
|
+
try {
|
|
297
|
+
const mod = await buildInjectModule({ noCache: process.env.REFINE_INJECT_NOCACHE === "1" });
|
|
298
|
+
res.writeHead(200, {
|
|
299
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
300
|
+
"Access-Control-Allow-Origin": "*",
|
|
301
|
+
"Cache-Control": "no-store",
|
|
302
|
+
});
|
|
303
|
+
return res.end(mod);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return send(res, 500, { error: String(e.message || e) });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// POST /jobs — browser enqueues a refine request.
|
|
310
|
+
if (method === "POST" && path === "/jobs") {
|
|
311
|
+
const body = await readJson(req);
|
|
312
|
+
if (!body || typeof body.request !== "object" || body.request === null) {
|
|
313
|
+
return send(res, 400, { error: "Body must be { request: {...} }" });
|
|
314
|
+
}
|
|
315
|
+
const job = createJob(body.request);
|
|
316
|
+
const mode = job.request.mode || (llmAvailable() ? "llm" : "deterministic");
|
|
317
|
+
job.request.mode = mode;
|
|
318
|
+
|
|
319
|
+
if (!AUTO) {
|
|
320
|
+
// External-poller-only mode: everything waits on GET /jobs/next.
|
|
321
|
+
} else if (mode === "deterministic") {
|
|
322
|
+
setImmediate(() => answerJob(job)); // in-process, off the response path
|
|
323
|
+
} else if (AGENT_CMD) {
|
|
324
|
+
setImmediate(() => answerJob(job)); // spawn the configured CLI once
|
|
325
|
+
} else if (pollerActive()) {
|
|
326
|
+
// A `/refine live` agent is polling — leave it pending for them to claim.
|
|
327
|
+
job.statusLog.push({ message: "Waiting for your agent (/refine live)…", at: now() });
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
if (job.status === "pending" || job.status === "working") {
|
|
330
|
+
job.status = "error";
|
|
331
|
+
job.error = "No agent answered in time. Is `/refine live` still running?";
|
|
332
|
+
job.updatedAt = now();
|
|
333
|
+
}
|
|
334
|
+
}, PENDING_TIMEOUT_MS);
|
|
335
|
+
} else {
|
|
336
|
+
job.status = "error";
|
|
337
|
+
job.error =
|
|
338
|
+
"LLM mode needs a live agent. In Cursor/Codex run `/refine live`, " +
|
|
339
|
+
"or start the relay with REFINE_AGENT_CMD set — or use the Deterministic tab.";
|
|
340
|
+
}
|
|
341
|
+
return send(res, 201, { id: job.id, status: job.status });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
|
|
345
|
+
if (method === "GET" && path === "/jobs/next") {
|
|
346
|
+
lastPollAt = now();
|
|
347
|
+
const deadline = now() + LONGPOLL_MS;
|
|
348
|
+
const attempt = () => {
|
|
349
|
+
if (res.writableEnded) return;
|
|
350
|
+
const job = nextPendingLlm();
|
|
351
|
+
if (job) {
|
|
352
|
+
job.status = "working";
|
|
353
|
+
job.updatedAt = now();
|
|
354
|
+
return send(res, 200, { id: job.id, request: job.request });
|
|
355
|
+
}
|
|
356
|
+
if (now() >= deadline) return send(res, 204);
|
|
357
|
+
setTimeout(attempt, 400);
|
|
358
|
+
};
|
|
359
|
+
return attempt();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const m = path.match(/^\/jobs\/([^/]+)(?:\/(status|result|error))?$/);
|
|
363
|
+
if (m) {
|
|
364
|
+
const job = jobs.get(m[1]);
|
|
365
|
+
if (!job) return send(res, 404, { error: "No such job" });
|
|
366
|
+
const sub = m[2];
|
|
367
|
+
|
|
368
|
+
if (method === "GET" && !sub) {
|
|
369
|
+
return send(res, 200, {
|
|
370
|
+
id: job.id,
|
|
371
|
+
status: job.status,
|
|
372
|
+
statusLog: job.statusLog,
|
|
373
|
+
result: job.result,
|
|
374
|
+
error: job.error,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (method === "POST" && sub === "status") {
|
|
379
|
+
const body = await readJson(req);
|
|
380
|
+
const message = body && typeof body.message === "string" ? body.message : null;
|
|
381
|
+
if (!message) return send(res, 400, { error: "Body must be { message }" });
|
|
382
|
+
job.statusLog.push({ message, at: now() });
|
|
383
|
+
job.updatedAt = now();
|
|
384
|
+
return send(res, 200, { ok: true });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (method === "POST" && sub === "result") {
|
|
388
|
+
const body = await readJson(req);
|
|
389
|
+
const suggestions = body && Array.isArray(body.suggestions) ? body.suggestions : null;
|
|
390
|
+
if (!suggestions) return send(res, 400, { error: "Body must be { suggestions: [...] }" });
|
|
391
|
+
job.result = { suggestions, summary: body.summary ?? null };
|
|
392
|
+
job.status = "done";
|
|
393
|
+
job.updatedAt = now();
|
|
394
|
+
return send(res, 200, { ok: true });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (method === "POST" && sub === "error") {
|
|
398
|
+
const body = await readJson(req);
|
|
399
|
+
job.error = (body && body.message) || "Agent reported an error";
|
|
400
|
+
job.status = "error";
|
|
401
|
+
job.updatedAt = now();
|
|
402
|
+
return send(res, 200, { ok: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return send(res, 404, { error: "Not found" });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
server.listen(PORT, () => {
|
|
410
|
+
console.log(`refine relay listening on http://localhost:${PORT}`);
|
|
411
|
+
console.log(` timeline injectable at http://localhost:${PORT}/inject.js`);
|
|
412
|
+
if (!AUTO) {
|
|
413
|
+
console.log(" auto-answer OFF (REFINE_AUTO=0) — all jobs wait for a poller on GET /jobs/next");
|
|
414
|
+
} else if (AGENT_CMD) {
|
|
415
|
+
console.log(` LLM jobs answered by spawning: ${AGENT_CMD}`);
|
|
416
|
+
} else {
|
|
417
|
+
console.log(" LLM jobs wait for a live agent — run `/refine live` in Cursor/Codex.");
|
|
418
|
+
console.log(` live agent stays 'available' for ${Math.round(POLLER_TTL_MS / 1000)}s after its last poll.`);
|
|
419
|
+
console.log(" Deterministic jobs answered in-process (nearest motion token).");
|
|
420
|
+
}
|
|
421
|
+
});
|